-
module ApplicationCable
-
class Channel < ActionCable::Channel::Base
-
end
-
end
-
module ApplicationCable
-
class Connection < ActionCable::Connection::Base
-
end
-
end
-
class BuilderPreviewChannel < ApplicationCable::Channel
-
def subscribed
-
# Subscribe to a specific builder theme's preview updates
-
stream_from "builder_preview_#{params[:theme_id]}"
-
end
-
-
def unsubscribed
-
# Any cleanup needed when channel is unsubscribed
-
end
-
-
def receive(data)
-
# Handle incoming data from the client
-
case data['type']
-
when 'preview_ready'
-
# Client is ready to receive updates
-
transmit({ type: 'ack', message: 'Preview ready' })
-
when 'request_update'
-
# Client is requesting a fresh update
-
broadcast_update(data['theme_id'])
-
end
-
end
-
-
private
-
-
def broadcast_update(theme_id)
-
# Send current theme state to the preview
-
builder_theme = BuilderTheme.find(theme_id)
-
-
ActionCable.server.broadcast(
-
"builder_preview_#{theme_id}",
-
{
-
type: 'theme_update',
-
theme_id: theme_id,
-
sections: builder_theme.sections_data,
-
settings: builder_theme.settings_data,
-
timestamp: Time.current.to_i
-
}
-
)
-
end
-
end
-
-
class RealtimeAnalyticsChannel < ApplicationCable::Channel
-
def subscribed
-
# Subscribe to real-time analytics updates
-
stream_from "realtime_analytics"
-
-
# Send initial data
-
send_realtime_data
-
end
-
-
def unsubscribed
-
# Any cleanup needed when channel is unsubscribed
-
end
-
-
private
-
-
def send_realtime_data
-
data = {
-
active_users: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
-
current_pageviews: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
-
unique_sessions: Pageview.where(created_at: 10.minutes.ago..Time.current).distinct.count(:session_id),
-
active_countries: Pageview.where(created_at: 10.minutes.ago..Time.current).where.not(country_name: [nil, '']).distinct.count(:country_name),
-
recent_views: Pageview.where(created_at: 10.minutes.ago..Time.current)
-
.order(created_at: :desc)
-
.limit(10)
-
.map do |pv|
-
{
-
path: pv.path,
-
country: pv.country_name,
-
browser: pv.browser,
-
device: pv.device,
-
created_at: pv.created_at.iso8601
-
}
-
end,
-
timestamp: Time.current.iso8601
-
}
-
-
transmit(data)
-
end
-
end
-
class AdminConstraint
-
def matches?(request)
-
# Get session data
-
session = request.session
-
-
# Check if admin user ID exists in session
-
admin_user_id = session[:admin_user_id]
-
return false unless admin_user_id
-
-
# Verify user exists and is active
-
begin
-
user = User.find_by(id: admin_user_id, is_active: true)
-
return false unless user
-
-
# Check if user has admin access
-
user.root? || user.account_access_level&.can_manage_account?
-
rescue => e
-
Rails.logger.error "Admin constraint error: #{e.message}"
-
false
-
end
-
end
-
end
-
class Admin::AccessLevelsController < Admin::BaseController
-
before_action :ensure_admin
-
-
# GET /admin/access_levels
-
def index
-
@roles = load_roles_with_permissions
-
end
-
-
# PATCH /admin/access_levels/update_permissions
-
def update_permissions
-
# This would update role permissions if using a more complex permission system
-
# For now, we'll just show what permissions each role has
-
-
redirect_to admin_access_levels_path, notice: 'Role permissions are defined in the User model.'
-
end
-
-
private
-
-
def ensure_admin
-
unless current_user&.administrator?
-
redirect_to admin_root_path, alert: 'Access denied. Administrator privileges required.'
-
end
-
end
-
-
def load_roles_with_permissions
-
[
-
{
-
name: 'Administrator',
-
key: 'administrator',
-
description: 'Full access to all features and settings',
-
color: 'red',
-
user_count: User.administrator.count,
-
permissions: [
-
{ name: 'Full Admin Access', granted: true, description: 'Complete control over the site' },
-
{ name: 'Manage Users', granted: true, description: 'Create, edit, and delete users' },
-
{ name: 'Manage Plugins', granted: true, description: 'Activate and configure plugins' },
-
{ name: 'Manage Themes', granted: true, description: 'Change and customize themes' },
-
{ name: 'Manage Settings', granted: true, description: 'Configure all site settings' },
-
{ name: 'Publish Posts', granted: true, description: 'Publish any post or page' },
-
{ name: 'Edit Others Posts', granted: true, description: 'Edit content from other users' },
-
{ name: 'Delete Posts', granted: true, description: 'Delete any post or page' },
-
{ name: 'Moderate Comments', granted: true, description: 'Approve, edit, delete comments' },
-
{ name: 'Upload Files', granted: true, description: 'Upload to media library' },
-
{ name: 'API Access', granted: true, description: 'Access REST and GraphQL APIs' }
-
]
-
},
-
{
-
name: 'Editor',
-
key: 'editor',
-
description: 'Can publish and manage posts including those of other users',
-
color: 'blue',
-
user_count: User.editor.count,
-
permissions: [
-
{ name: 'Full Admin Access', granted: false, description: 'Complete control over the site' },
-
{ name: 'Manage Users', granted: false, description: 'Create, edit, and delete users' },
-
{ name: 'Manage Plugins', granted: false, description: 'Activate and configure plugins' },
-
{ name: 'Manage Themes', granted: false, description: 'Change and customize themes' },
-
{ name: 'Manage Settings', granted: false, description: 'Configure all site settings' },
-
{ name: 'Publish Posts', granted: true, description: 'Publish any post or page' },
-
{ name: 'Edit Others Posts', granted: true, description: 'Edit content from other users' },
-
{ name: 'Delete Posts', granted: true, description: 'Delete any post or page' },
-
{ name: 'Moderate Comments', granted: true, description: 'Approve, edit, delete comments' },
-
{ name: 'Upload Files', granted: true, description: 'Upload to media library' },
-
{ name: 'API Access', granted: true, description: 'Access REST and GraphQL APIs' }
-
]
-
},
-
{
-
name: 'Author',
-
key: 'author',
-
description: 'Can publish and manage their own posts',
-
color: 'green',
-
user_count: User.author.count,
-
permissions: [
-
{ name: 'Full Admin Access', granted: false, description: 'Complete control over the site' },
-
{ name: 'Manage Users', granted: false, description: 'Create, edit, and delete users' },
-
{ name: 'Manage Plugins', granted: false, description: 'Activate and configure plugins' },
-
{ name: 'Manage Themes', granted: false, description: 'Change and customize themes' },
-
{ name: 'Manage Settings', granted: false, description: 'Configure all site settings' },
-
{ name: 'Publish Posts', granted: true, description: 'Publish their own posts and pages' },
-
{ name: 'Edit Others Posts', granted: false, description: 'Edit content from other users' },
-
{ name: 'Delete Posts', granted: false, description: 'Delete only their own content' },
-
{ name: 'Moderate Comments', granted: false, description: 'Limited comment moderation' },
-
{ name: 'Upload Files', granted: true, description: 'Upload to media library' },
-
{ name: 'API Access', granted: true, description: 'Access REST and GraphQL APIs' }
-
]
-
},
-
{
-
name: 'Contributor',
-
key: 'contributor',
-
description: 'Can write and manage their own posts but cannot publish',
-
color: 'yellow',
-
user_count: User.contributor.count,
-
permissions: [
-
{ name: 'Full Admin Access', granted: false, description: 'Complete control over the site' },
-
{ name: 'Manage Users', granted: false, description: 'Create, edit, and delete users' },
-
{ name: 'Manage Plugins', granted: false, description: 'Activate and configure plugins' },
-
{ name: 'Manage Themes', granted: false, description: 'Change and customize themes' },
-
{ name: 'Manage Settings', granted: false, description: 'Configure all site settings' },
-
{ name: 'Publish Posts', granted: false, description: 'Can only submit for review' },
-
{ name: 'Edit Others Posts', granted: false, description: 'Edit content from other users' },
-
{ name: 'Delete Posts', granted: false, description: 'Cannot delete posts' },
-
{ name: 'Moderate Comments', granted: false, description: 'Cannot moderate comments' },
-
{ name: 'Upload Files', granted: false, description: 'Cannot upload files' },
-
{ name: 'API Access', granted: true, description: 'Limited API access' }
-
]
-
},
-
{
-
name: 'Subscriber',
-
key: 'subscriber',
-
description: 'Can only manage their profile',
-
color: 'gray',
-
user_count: User.subscriber.count,
-
permissions: [
-
{ name: 'Full Admin Access', granted: false, description: 'Complete control over the site' },
-
{ name: 'Manage Users', granted: false, description: 'Create, edit, and delete users' },
-
{ name: 'Manage Plugins', granted: false, description: 'Activate and configure plugins' },
-
{ name: 'Manage Themes', granted: false, description: 'Change and customize themes' },
-
{ name: 'Manage Settings', granted: false, description: 'Configure all site settings' },
-
{ name: 'Publish Posts', granted: false, description: 'Cannot create or publish posts' },
-
{ name: 'Edit Others Posts', granted: false, description: 'Cannot edit any posts' },
-
{ name: 'Delete Posts', granted: false, description: 'Cannot delete posts' },
-
{ name: 'Moderate Comments', granted: false, description: 'Cannot moderate comments' },
-
{ name: 'Upload Files', granted: false, description: 'Cannot upload files' },
-
{ name: 'API Access', granted: false, description: 'No API access' }
-
]
-
}
-
]
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::AiAgentsController < Admin::BaseController
-
before_action :set_ai_agent, only: [:show, :edit, :update, :destroy, :toggle, :test]
-
-
# GET /admin/ai_agents
-
def index
-
@ai_agents = AiAgent.includes(:ai_provider).ordered
-
end
-
-
# GET /admin/ai_agents/usage
-
def usage
-
@ai_agents = AiAgent.includes(:ai_provider).ordered
-
-
# Calculate usage statistics for all agents
-
@total_usage = calculate_total_usage
-
-
# Calculate usage statistics for each agent
-
@agent_usage = @ai_agents.map do |agent|
-
{
-
agent: agent,
-
usage_stats: calculate_agent_usage(agent)
-
}
-
end
-
end
-
-
# GET /admin/ai_agents/:id
-
def show
-
end
-
-
# GET /admin/ai_agents/new
-
def new
-
@ai_agent = AiAgent.new
-
@ai_providers = AiProvider.active.ordered
-
end
-
-
# POST /admin/ai_agents
-
def create
-
@ai_agent = AiAgent.new(ai_agent_params)
-
@ai_providers = AiProvider.active.ordered
-
-
if @ai_agent.save
-
redirect_to admin_ai_agents_path, notice: 'AI Agent created successfully.'
-
else
-
render :new, status: :unprocessable_content
-
end
-
end
-
-
# GET /admin/ai_agents/:id/edit
-
def edit
-
@ai_providers = AiProvider.active.ordered
-
end
-
-
# PATCH/PUT /admin/ai_agents/:id
-
def update
-
@ai_providers = AiProvider.active.ordered
-
-
if @ai_agent.update(ai_agent_params)
-
redirect_to admin_ai_agents_path, notice: 'AI Agent updated successfully.'
-
else
-
render :edit, status: :unprocessable_content
-
end
-
end
-
-
# DELETE /admin/ai_agents/:id
-
def destroy
-
@ai_agent.destroy
-
redirect_to admin_ai_agents_path, notice: 'AI Agent deleted successfully.'
-
end
-
-
# PATCH /admin/ai_agents/:id/toggle
-
def toggle
-
@ai_agent.update(active: !@ai_agent.active)
-
redirect_to admin_ai_agents_path, notice: "AI Agent #{@ai_agent.active? ? 'activated' : 'deactivated'}."
-
end
-
-
# POST /admin/ai_agents/:id/test
-
def test
-
user_input = params[:user_input] || "Test input"
-
context = params[:context] || {}
-
-
begin
-
result = @ai_agent.execute(user_input, context, current_user)
-
-
respond_to do |format|
-
format.json { render json: { success: true, result: result } }
-
format.html { redirect_to admin_ai_agent_path(@ai_agent), notice: "Test completed successfully." }
-
end
-
rescue => e
-
Rails.logger.error "AI Agent test failed: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
-
respond_to do |format|
-
format.json { render json: { success: false, error: e.message }, status: :unprocessable_content }
-
format.html { redirect_to admin_ai_agent_path(@ai_agent), alert: "Test failed: #{e.message}" }
-
end
-
end
-
end
-
-
private
-
-
def set_ai_agent
-
@ai_agent = AiAgent.find(params[:id])
-
end
-
-
def ai_agent_params
-
params.require(:ai_agent).permit(
-
:name, :description, :agent_type, :prompt, :content, :guidelines,
-
:rules, :tasks, :master_prompt, :ai_provider_id, :active, :position
-
)
-
end
-
-
def calculate_total_usage
-
# Calculate total usage across all agents from real data
-
{
-
total_requests: AiUsage.count,
-
total_tokens: AiUsage.sum(:tokens_used),
-
total_cost: AiUsage.sum(:cost),
-
requests_today: AiUsage.today.count,
-
requests_this_month: AiUsage.this_month.count,
-
average_response_time: AiUsage.average(:response_time)&.round(2) || 0
-
}
-
end
-
-
def calculate_agent_usage(agent)
-
# Calculate usage statistics for a specific agent from real data
-
{
-
total_requests: agent.total_requests,
-
total_tokens: agent.total_tokens,
-
total_cost: agent.total_cost,
-
requests_today: agent.requests_today,
-
requests_this_month: agent.requests_this_month,
-
average_response_time: agent.average_response_time,
-
last_used: agent.last_used,
-
success_rate: agent.success_rate
-
}
-
end
-
end
-
class Admin::AiDemoController < Admin::BaseController
-
def index
-
# Demo page for AI text generator functionality
-
# No authentication required for demo purposes
-
end
-
end
-
-
-
-
-
class Admin::AiProvidersController < Admin::BaseController
-
before_action :set_ai_provider, only: [:show, :edit, :update, :destroy, :toggle]
-
-
# GET /admin/ai_providers
-
def index
-
@ai_providers = AiProvider.ordered.includes(:ai_agents)
-
end
-
-
# GET /admin/ai_providers/:id
-
def show
-
end
-
-
# GET /admin/ai_providers/new
-
def new
-
@ai_provider = AiProvider.new
-
end
-
-
# POST /admin/ai_providers
-
def create
-
@ai_provider = AiProvider.new(ai_provider_params)
-
-
if @ai_provider.save
-
redirect_to admin_ai_providers_path, notice: 'AI Provider created successfully.'
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# GET /admin/ai_providers/:id/edit
-
def edit
-
end
-
-
# PATCH/PUT /admin/ai_providers/:id
-
def update
-
update_params = ai_provider_params
-
-
# Don't update API key if it's the placeholder
-
if update_params[:api_key] == "••••••••••••••••"
-
update_params.delete(:api_key)
-
end
-
-
if @ai_provider.update(update_params)
-
redirect_to admin_ai_providers_path, notice: 'AI Provider updated successfully.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/ai_providers/:id
-
def destroy
-
if @ai_provider.ai_agents.any?
-
redirect_to admin_ai_providers_path, alert: 'Cannot delete provider with active agents. Delete agents first.'
-
else
-
@ai_provider.destroy
-
redirect_to admin_ai_providers_path, notice: 'AI Provider deleted successfully.'
-
end
-
end
-
-
# PATCH /admin/ai_providers/:id/toggle
-
def toggle
-
@ai_provider.update(active: !@ai_provider.active)
-
redirect_to admin_ai_providers_path, notice: "AI Provider #{@ai_provider.active? ? 'activated' : 'deactivated'}."
-
end
-
-
private
-
-
def set_ai_provider
-
@ai_provider = AiProvider.find(params[:id])
-
end
-
-
def ai_provider_params
-
params.require(:ai_provider).permit(
-
:name, :provider_type, :api_key, :api_url, :model_identifier,
-
:max_tokens, :temperature, :active, :position
-
)
-
end
-
end
-
class Admin::AnalyticsController < Admin::BaseController
-
def index
-
@period = params[:period] || 'month'
-
range = period_range(@period)
-
-
# Core metrics
-
@total_pageviews = Pageview.where(created_at: range).count
-
@unique_visitors = Pageview.where(created_at: range).distinct.count(:session_id)
-
@avg_session_duration = calculate_avg_session_duration(range)
-
@bounce_rate = calculate_bounce_rate(range)
-
@pages_per_session = calculate_pages_per_session(range)
-
@current_pageviews = Pageview.where(created_at: 1.hour.ago..Time.current).count
-
-
# Country data
-
@country_data = Pageview.where(created_at: range)
-
.where.not(country_name: [nil, ''])
-
.group(:country_name)
-
.count
-
.map { |name, count| { name: name, count: count } }
-
.sort_by { |item| -item[:count] }
-
.first(10)
-
-
# Device data
-
@device_data = Pageview.where(created_at: range)
-
.where.not(device: [nil, ''])
-
.group(:device)
-
.count
-
.map { |type, count| { type: type, count: count } }
-
.sort_by { |item| -item[:count] }
-
.first(10)
-
-
# Top referrers
-
@top_referrers = Pageview.where(visited_at: range)
-
.where.not(referrer: [nil, ''])
-
.group(:referrer)
-
.count
-
.map { |referrer, count| { referrer: referrer, count: count } }
-
.sort_by { |item| -item[:count] }
-
.first(10)
-
-
# Traffic data for charts
-
@traffic_data = Pageview.where(created_at: range)
-
.group("DATE(created_at)")
-
.count
-
.map { |date, count| { date: date.to_s, count: count } }
-
-
# Top pages
-
@top_pages = Pageview.where(created_at: range)
-
.where.not(path: [nil, ''])
-
.group(:path)
-
.count
-
.map { |path, count| { path: path, count: count } }
-
.sort_by { |item| -item[:count] }
-
.first(10)
-
-
# Browser and device stats
-
@browser_stats = Pageview.where(created_at: range)
-
.where.not(browser: [nil, ''])
-
.group(:browser)
-
.count
-
-
@device_stats = Pageview.where(created_at: range)
-
.where.not(device: [nil, ''])
-
.group(:device)
-
.count
-
-
@os_stats = Pageview.where(created_at: range)
-
.where.not(os: [nil, ''])
-
.group(:os)
-
.count
-
-
# Set audience insights
-
@audience_insights = AnalyticsService.audience_insights(period: @period)
-
@operating_systems = @audience_insights[:operating_systems] || []
-
end
-
-
-
# GET /admin/analytics/realtime
-
def realtime
-
# REAL-TIME: Last 10 minutes only
-
@current_pageviews = Pageview.where(created_at: 10.minutes.ago..Time.current).count
-
@recent_pageviews = Pageview.where(created_at: 10.minutes.ago..Time.current)
-
.order(created_at: :desc)
-
.limit(50)
-
end
-
-
# GET /admin/analytics/insights
-
def insights
-
@period = params[:period] || 'month'
-
insights = AnalyticsService.generate_insights(period: @period)
-
-
render json: insights
-
end
-
-
# GET /admin/analytics/posts
-
def posts
-
@period = params[:period] || 'month'
-
range = period_range(@period)
-
-
@top_posts = Pageview.consented_only
-
.non_bot
-
.where(visited_at: range)
-
.where.not(post_id: nil)
-
.group(:post_id)
-
.order('count_id DESC')
-
.limit(50)
-
.count(:id)
-
.map do |post_id, count|
-
post = Post.find_by(id: post_id)
-
{
-
post: post,
-
post_id: post_id,
-
title: post&.title || "Deleted Post ##{post_id}",
-
views: count,
-
unique: Pageview.consented_only.non_bot.where(post_id: post_id, visited_at: range, unique_visitor: true).count
-
}
-
end
-
end
-
-
# GET /admin/analytics/pages
-
def pages
-
@period = params[:period] || 'month'
-
range = period_range(@period)
-
-
@top_pages = Pageview.consented_only
-
.non_bot
-
.where(visited_at: range)
-
.where.not(page_id: nil)
-
.group(:page_id)
-
.order('count_id DESC')
-
.limit(50)
-
.count(:id)
-
.map do |page_id, count|
-
page_obj = Page.find_by(id: page_id)
-
{
-
page: page_obj,
-
views: count,
-
unique: Pageview.consented_only.non_bot.where(page_id: page_id, visited_at: range, unique_visitor: true).count
-
}
-
end
-
end
-
-
# GET /admin/analytics/countries
-
def countries
-
@period = params[:period] || 'month'
-
range = period_range(@period)
-
-
@country_stats = Pageview.consented_only
-
.non_bot
-
.where(visited_at: range)
-
.where.not(country_code: nil)
-
.group(:country_code)
-
.order('count_id DESC')
-
.count(:id)
-
.map { |code, count| { code: code, name: country_name(code), count: count } }
-
end
-
-
# GET /admin/analytics/browsers
-
def browsers
-
@period = params[:period] || 'month'
-
range = period_range(@period)
-
-
@browser_stats = Pageview.consented_only.non_bot.where(visited_at: range).group(:browser).count
-
@device_stats = Pageview.consented_only.non_bot.where(visited_at: range).group(:device).count
-
@os_stats = Pageview.consented_only.non_bot.where(visited_at: range).group(:os).count
-
end
-
-
# GET /admin/analytics/referrers
-
def referrers
-
@period = params[:period] || 'month'
-
range = period_range(@period)
-
-
@referrer_stats = Pageview.consented_only
-
.non_bot
-
.where(visited_at: range)
-
.where.not(referrer: [nil, ''])
-
.group(:referrer)
-
.order('count_id DESC')
-
.limit(50)
-
.count(:id)
-
end
-
-
# GET /admin/analytics/export
-
def export
-
@period = params[:period] || 'month'
-
range = period_range(@period)
-
-
pageviews = Pageview.consented_only
-
.where(visited_at: range)
-
.order(visited_at: :desc)
-
-
csv_data = generate_csv(pageviews)
-
-
send_data csv_data,
-
filename: "analytics-#{@period}-#{Date.today}.csv",
-
type: 'text/csv',
-
disposition: 'attachment'
-
end
-
-
# POST /admin/analytics/purge
-
def purge
-
days = params[:days]&.to_i || 90
-
-
case params[:purge_type]
-
when 'anonymize'
-
Pageview.anonymize_old_data(days)
-
message = "Data older than #{days} days has been anonymized."
-
when 'delete_non_consented'
-
count = Pageview.purge_non_consented(days)
-
message = "Deleted #{count} non-consented pageviews older than #{days} days."
-
when 'delete_all'
-
count = Pageview.where('created_at < ?', days.days.ago).delete_all
-
message = "Deleted #{count} pageviews older than #{days} days."
-
else
-
message = "Invalid purge type."
-
end
-
-
redirect_to admin_analytics_path, notice: message
-
end
-
-
# POST /analytics/events (for custom event tracking)
-
def track_event
-
return head :unauthorized unless request.xhr? || request.content_type&.include?('application/json')
-
-
begin
-
event_data = JSON.parse(request.body.read)
-
-
# Create analytics event
-
event = AnalyticsEvent.create!(
-
event_name: event_data['event_name'],
-
properties: event_data['properties'] || {},
-
session_id: event_data['properties']&.dig('session_id') || generate_session_id,
-
user_id: current_user&.id,
-
path: event_data['properties']&.dig('path') || request.path,
-
tenant: ActsAsTenant.current_tenant
-
)
-
-
render json: { success: true, event_id: event.id }
-
rescue => e
-
Rails.logger.error "Failed to track custom event: #{e.message}"
-
render json: { success: false, error: e.message }, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def period_range(period)
-
case period.to_sym
-
when :today
-
Time.current.beginning_of_day..Time.current.end_of_day
-
when :week
-
1.week.ago..Time.current
-
when :month
-
1.month.ago..Time.current
-
when :year
-
1.year.ago..Time.current
-
else
-
1.month.ago..Time.current
-
end
-
end
-
-
def calculate_consent_rate
-
total = Pageview.count
-
return 0 if total.zero?
-
-
consented = Pageview.consented_only.count
-
((consented.to_f / total) * 100).round(1)
-
end
-
-
def country_name(code)
-
# Simple country code to name mapping
-
countries = {
-
'US' => 'United States',
-
'GB' => 'United Kingdom',
-
'CA' => 'Canada',
-
'DE' => 'Germany',
-
'FR' => 'France',
-
'ES' => 'Spain',
-
'IT' => 'Italy',
-
'BR' => 'Brazil',
-
'JP' => 'Japan',
-
'CN' => 'China',
-
'IN' => 'India',
-
'AU' => 'Australia',
-
'MX' => 'Mexico',
-
'NL' => 'Netherlands'
-
}
-
-
countries[code] || code
-
end
-
-
def pageview_json(pageview)
-
{
-
id: pageview.id,
-
path: pageview.path,
-
title: pageview.title,
-
browser: pageview.browser,
-
device: pageview.device,
-
country: pageview.country_code,
-
visited_at: pageview.visited_at.strftime('%Y-%m-%d %H:%M:%S')
-
}
-
end
-
-
def generate_csv(pageviews)
-
require 'csv'
-
-
CSV.generate(headers: true) do |csv|
-
csv << ['Date', 'Time', 'Path', 'Title', 'Referrer', 'Country', 'Browser', 'Device', 'OS', 'Duration']
-
-
pageviews.each do |pv|
-
csv << [
-
pv.visited_at.strftime('%Y-%m-%d'),
-
pv.visited_at.strftime('%H:%M:%S'),
-
pv.path,
-
pv.title,
-
pv.referrer,
-
pv.country_code,
-
pv.browser,
-
pv.device,
-
pv.os,
-
pv.duration
-
]
-
end
-
end
-
end
-
-
def calculate_engagement_levels(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
-
high_engagement = pageviews.where('time_on_page > ? AND scroll_depth > ?', 60, 75).count
-
medium_engagement = pageviews.where('time_on_page BETWEEN ? AND ? AND scroll_depth BETWEEN ? AND ?', 30, 60, 25, 75).count
-
low_engagement = pageviews.where('time_on_page < ? OR scroll_depth < ?', 30, 25).count
-
-
[
-
{ level: 'high', count: high_engagement },
-
{ level: 'medium', count: medium_engagement },
-
{ level: 'low', count: low_engagement }
-
]
-
end
-
-
def calculate_device_breakdown(range)
-
Pageview.consented_only
-
.non_bot
-
.where(visited_at: range)
-
.where.not(device: nil)
-
.group(:device)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.map { |device, count| { device: device, count: count } }
-
end
-
-
def calculate_country_breakdown(range)
-
Pageview.consented_only
-
.non_bot
-
.where(visited_at: range)
-
.where.not(country_code: nil)
-
.group(:country_code)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.map { |country, count| { country: country, count: count } }
-
end
-
-
def calculate_performance_metrics(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
-
{
-
page_load_time: 85, # This would come from performance monitoring
-
time_to_interactive: 78,
-
first_contentful_paint: 92,
-
largest_contentful_paint: 88
-
}
-
end
-
-
def calculate_conversion_funnel(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
-
visitors = pageviews.distinct.count(:session_id)
-
page_views = pageviews.count
-
engaged_users = pageviews.where('time_on_page > ?', 30).distinct.count(:session_id)
-
readers = pageviews.where(is_reader: true).distinct.count(:session_id)
-
conversions = AnalyticsEvent.where(created_at: range, event_name: 'conversion').distinct.count(:session_id)
-
-
[
-
{ stage: 'Visitors', count: visitors },
-
{ stage: 'Page Views', count: page_views },
-
{ stage: 'Engaged Users', count: engaged_users },
-
{ stage: 'Readers', count: readers },
-
{ stage: 'Conversions', count: conversions }
-
]
-
end
-
-
# Advanced GA4/Matomo-level analytics methods
-
def calculate_traffic_sources(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
-
sources = pageviews.where.not(referrer: [nil, '']).group(:referrer).count(:id)
-
direct = pageviews.where(referrer: [nil, '']).count
-
-
{
-
organic: sources.select { |k, _| k.include?('google') || k.include?('bing') }.sum { |_, v| v },
-
social: sources.select { |k, _| k.include?('facebook') || k.include?('twitter') || k.include?('linkedin') }.sum { |_, v| v },
-
direct: direct,
-
referral: sources.reject { |k, _| k.include?('google') || k.include?('bing') || k.include?('facebook') || k.include?('twitter') || k.include?('linkedin') }.sum { |_, v| v }
-
}
-
end
-
-
def calculate_user_flow(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
-
# Calculate user flow through the site
-
entry_pages = pageviews.group(:session_id).minimum(:visited_at)
-
exit_pages = pageviews.group(:session_id).maximum(:visited_at)
-
-
{
-
entry_pages: entry_pages.values.group_by { |pv| Pageview.find(pv.id).path }.transform_values(&:count),
-
exit_pages: exit_pages.values.group_by { |pv| Pageview.find(pv.id).path }.transform_values(&:count),
-
avg_pages_per_session: pageviews.group(:session_id).count.values.mean || 0
-
}
-
end
-
-
def calculate_cohort_analysis(range)
-
# Simple cohort analysis by month
-
cohorts = {}
-
(0..12).each do |i|
-
month_start = i.months.ago.beginning_of_month
-
month_end = month_start.end_of_month
-
-
cohort_users = Pageview.where(visited_at: month_start..month_end).distinct.pluck(:session_id)
-
cohorts[month_start.strftime('%Y-%m')] = {
-
users: cohort_users.count,
-
retention: calculate_cohort_retention(cohort_users, month_start)
-
}
-
end
-
-
cohorts
-
end
-
-
def calculate_attribution_data(range)
-
# Multi-touch attribution analysis
-
sessions = Pageview.where(visited_at: range).distinct.pluck(:session_id)
-
-
{
-
first_touch: calculate_first_touch_attribution(sessions),
-
last_touch: calculate_last_touch_attribution(sessions),
-
linear: calculate_linear_attribution(sessions)
-
}
-
end
-
-
def calculate_bounce_rate(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
total_sessions = pageviews.distinct.count(:session_id)
-
return 0 if total_sessions.zero?
-
-
single_page_sessions_count = pageviews.group(:session_id).having('COUNT(*) = 1').count.size
-
(single_page_sessions_count.to_f / total_sessions * 100).round(2)
-
end
-
-
def calculate_avg_session_duration(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
sessions = pageviews.group(:session_id).sum(:time_on_page)
-
-
return 0 if sessions.empty?
-
(sessions.values.sum / sessions.count).round(2)
-
end
-
-
def calculate_pages_per_session(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
pages_per_session = pageviews.group(:session_id).count
-
-
return 0 if pages_per_session.empty?
-
(pages_per_session.values.sum / pages_per_session.count.to_f).round(2)
-
end
-
-
def calculate_conversion_rate(range)
-
total_sessions = Pageview.consented_only.non_bot.where(visited_at: range).distinct.count(:session_id)
-
conversions = AnalyticsEvent.where(created_at: range, event_name: 'conversion').distinct.count(:session_id)
-
-
return 0 if total_sessions.zero?
-
(conversions.to_f / total_sessions * 100).round(2)
-
end
-
-
def get_top_posts(range)
-
Pageview.consented_only
-
.non_bot
-
.where(visited_at: range)
-
.where.not(post_id: nil)
-
.group(:post_id)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.map do |post_id, count|
-
post = Post.find_by(id: post_id)
-
{
-
post: post,
-
post_id: post_id,
-
title: post&.title || "Deleted Post ##{post_id}",
-
views: count,
-
unique_readers: Pageview.consented_only.non_bot.where(post_id: post_id, visited_at: range, is_reader: true).count
-
}
-
end
-
end
-
-
def get_top_pages(range)
-
Pageview.consented_only
-
.non_bot
-
.where(visited_at: range)
-
.where.not(page_id: nil)
-
.group(:page_id)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.map do |page_id, count|
-
page = Page.find_by(id: page_id)
-
{
-
page: page,
-
page_id: page_id,
-
title: page&.title || "Deleted Page ##{page_id}",
-
views: count,
-
unique_readers: Pageview.consented_only.non_bot.where(page_id: page_id, visited_at: range, is_reader: true).count
-
}
-
end
-
end
-
-
def calculate_content_engagement(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
-
{
-
avg_reading_time: pageviews.average(:reading_time)&.round(2) || 0,
-
avg_scroll_depth: pageviews.average(:scroll_depth)&.round(2) || 0,
-
avg_completion_rate: pageviews.average(:completion_rate)&.round(2) || 0,
-
readers_count: pageviews.where(is_reader: true).count,
-
high_engagement_readers: pageviews.where(is_reader: true, engagement_score: 80..100).count
-
}
-
end
-
-
def calculate_geographic_insights(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
-
{
-
top_countries: pageviews.where.not(country_code: nil).group(:country_code).count.sort_by { |_, v| -v }.first(10),
-
top_cities: pageviews.where.not(city: nil).group(:city).count.sort_by { |_, v| -v }.first(10),
-
top_regions: pageviews.where.not(region: nil).group(:region).count.sort_by { |_, v| -v }.first(10)
-
}
-
end
-
-
def calculate_technology_insights(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
-
{
-
browsers: pageviews.where.not(browser: nil).group(:browser).count.sort_by { |_, v| -v },
-
devices: pageviews.where.not(device: nil).group(:device).count.sort_by { |_, v| -v },
-
operating_systems: pageviews.where.not(os: nil).group(:os).count.sort_by { |_, v| -v }
-
}
-
end
-
-
def calculate_time_insights(range)
-
pageviews = Pageview.consented_only.non_bot.where(visited_at: range)
-
-
{
-
hourly_distribution: pageviews.group_by_hour(:visited_at).count,
-
daily_distribution: pageviews.group_by_day(:visited_at).count,
-
weekly_distribution: pageviews.group_by_day_of_week(:visited_at).count,
-
monthly_distribution: pageviews.group_by_month(:visited_at).count
-
}
-
end
-
-
def calculate_cohort_retention(cohort_users, cohort_month)
-
# Calculate how many users from this cohort returned in subsequent months
-
returning_users = 0
-
(1..12).each do |i|
-
next_month = cohort_month + i.months
-
if next_month <= Time.current
-
next_month_users = Pageview.where(visited_at: next_month.beginning_of_month..next_month.end_of_month)
-
.where(session_id: cohort_users)
-
.distinct
-
.pluck(:session_id)
-
returning_users += next_month_users.count
-
end
-
end
-
-
cohort_users.empty? ? 0 : (returning_users.to_f / cohort_users.count * 100).round(2)
-
end
-
-
def calculate_first_touch_attribution(sessions)
-
# Calculate first-touch attribution
-
first_touches = {}
-
sessions.each do |session_id|
-
first_pageview = Pageview.where(session_id: session_id).order(:visited_at).first
-
next unless first_pageview&.referrer.present?
-
-
source = categorize_traffic_source(first_pageview.referrer)
-
first_touches[source] = (first_touches[source] || 0) + 1
-
end
-
first_touches
-
end
-
-
def calculate_last_touch_attribution(sessions)
-
# Calculate last-touch attribution
-
last_touches = {}
-
sessions.each do |session_id|
-
last_pageview = Pageview.where(session_id: session_id).order(:visited_at).last
-
next unless last_pageview&.referrer.present?
-
-
source = categorize_traffic_source(last_pageview.referrer)
-
last_touches[source] = (last_touches[source] || 0) + 1
-
end
-
last_touches
-
end
-
-
def calculate_linear_attribution(sessions)
-
# Calculate linear attribution (equal weight to all touchpoints)
-
linear_attribution = {}
-
sessions.each do |session_id|
-
pageviews = Pageview.where(session_id: session_id).where.not(referrer: [nil, ''])
-
next if pageviews.empty?
-
-
weight = 1.0 / pageviews.count
-
pageviews.each do |pv|
-
source = categorize_traffic_source(pv.referrer)
-
linear_attribution[source] = (linear_attribution[source] || 0) + weight
-
end
-
end
-
linear_attribution
-
end
-
-
def categorize_traffic_source(referrer)
-
return 'Direct' if referrer.blank?
-
-
if referrer.include?('google') || referrer.include?('bing') || referrer.include?('yahoo')
-
'Organic Search'
-
elsif referrer.include?('facebook') || referrer.include?('twitter') || referrer.include?('linkedin') || referrer.include?('instagram')
-
'Social Media'
-
elsif referrer.include?('mail') || referrer.include?('email')
-
'Email'
-
else
-
'Referral'
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
require 'redcarpet'
-
-
class Admin::ApiDocsController < Admin::BaseController
-
def index
-
# Main API documentation landing page
-
end
-
-
def rest
-
# REST API documentation
-
load_rest_endpoints
-
end
-
-
def graphql
-
# GraphQL playground (moved from /graphiql)
-
if Rails.env.production?
-
redirect_to admin_api_docs_path, alert: "GraphQL playground is only available in development mode."
-
else
-
render layout: false
-
end
-
end
-
-
def graphql_schema
-
# GraphQL schema documentation
-
@schema = RailspressSchema
-
@types = @schema.types.values.reject { |type| type.graphql_name.start_with?('__') }
-
@queries = @schema.query.fields.values
-
@mutations = @schema.mutation&.fields&.values || []
-
end
-
-
def themes
-
# Theme development documentation
-
@theme_docs = load_theme_docs
-
@theme_docs_content = load_html_documentation('theme-development.html')
-
end
-
-
def plugins
-
# Plugin development documentation
-
@plugin_docs = load_plugin_docs
-
@plugin_docs_content = load_html_documentation('plugin-development.html')
-
end
-
-
private
-
-
def load_rest_endpoints
-
@endpoints = {
-
authentication: {
-
title: "Authentication",
-
description: "Endpoints for user authentication and token management",
-
endpoints: [
-
{
-
method: "POST",
-
path: "/api/v1/auth/login",
-
description: "Login and receive an API token",
-
params: {
-
email: "string (required)",
-
password: "string (required)"
-
},
-
response: {
-
token: "string",
-
user: "object"
-
}
-
},
-
{
-
method: "POST",
-
path: "/api/v1/auth/register",
-
description: "Register a new user account",
-
params: {
-
email: "string (required)",
-
password: "string (required)",
-
password_confirmation: "string (required)",
-
name: "string (optional)"
-
},
-
response: {
-
token: "string",
-
user: "object"
-
}
-
},
-
{
-
method: "POST",
-
path: "/api/v1/auth/validate",
-
description: "Validate an API token",
-
headers: {
-
Authorization: "Bearer YOUR_TOKEN"
-
},
-
response: {
-
valid: "boolean",
-
user: "object"
-
}
-
}
-
]
-
},
-
posts: {
-
title: "Posts",
-
description: "Create, read, update, and delete blog posts",
-
endpoints: [
-
{
-
method: "GET",
-
path: "/api/v1/posts",
-
description: "List all posts with pagination",
-
params: {
-
page: "integer (default: 1)",
-
per_page: "integer (default: 20)",
-
status: "string (published, draft, private_post)",
-
search: "string (search query)"
-
},
-
response: {
-
posts: "array",
-
meta: {
-
total: "integer",
-
page: "integer",
-
per_page: "integer",
-
total_pages: "integer"
-
}
-
}
-
},
-
{
-
method: "GET",
-
path: "/api/v1/posts/:id",
-
description: "Get a specific post by ID",
-
response: {
-
id: "integer",
-
title: "string",
-
content: "string",
-
excerpt: "string",
-
slug: "string",
-
status: "string",
-
published_at: "datetime",
-
categories: "array",
-
tags: "array",
-
user: "object"
-
}
-
},
-
{
-
method: "POST",
-
path: "/api/v1/posts",
-
description: "Create a new post",
-
requires_auth: true,
-
params: {
-
title: "string (required)",
-
content: "string (required)",
-
excerpt: "string (optional)",
-
slug: "string (optional)",
-
status: "string (default: draft)",
-
category_ids: "array of integers",
-
tag_ids: "array of integers"
-
},
-
response: {
-
post: "object",
-
message: "string"
-
}
-
},
-
{
-
method: "PUT",
-
path: "/api/v1/posts/:id",
-
description: "Update an existing post",
-
requires_auth: true,
-
params: {
-
title: "string",
-
content: "string",
-
excerpt: "string",
-
status: "string",
-
category_ids: "array of integers",
-
tag_ids: "array of integers"
-
}
-
},
-
{
-
method: "DELETE",
-
path: "/api/v1/posts/:id",
-
description: "Delete a post",
-
requires_auth: true,
-
response: {
-
message: "string"
-
}
-
}
-
]
-
},
-
pages: {
-
title: "Pages",
-
description: "Manage static pages",
-
endpoints: [
-
{
-
method: "GET",
-
path: "/api/v1/pages",
-
description: "List all pages"
-
},
-
{
-
method: "GET",
-
path: "/api/v1/pages/:id",
-
description: "Get a specific page"
-
},
-
{
-
method: "POST",
-
path: "/api/v1/pages",
-
description: "Create a new page",
-
requires_auth: true
-
},
-
{
-
method: "PUT",
-
path: "/api/v1/pages/:id",
-
description: "Update a page",
-
requires_auth: true
-
},
-
{
-
method: "DELETE",
-
path: "/api/v1/pages/:id",
-
description: "Delete a page",
-
requires_auth: true
-
}
-
]
-
},
-
taxonomies: {
-
title: "Taxonomies & Terms",
-
description: "Manage taxonomies (categories, tags, custom) and their terms",
-
endpoints: [
-
{
-
method: "GET",
-
path: "/api/v1/taxonomies",
-
description: "List all taxonomies",
-
response: {
-
taxonomies: "array of taxonomy objects"
-
}
-
},
-
{
-
method: "GET",
-
path: "/api/v1/taxonomies/:id",
-
description: "Get a specific taxonomy with its terms"
-
},
-
{
-
method: "GET",
-
path: "/api/v1/taxonomies/:id/terms",
-
description: "Get all terms for a taxonomy"
-
},
-
{
-
method: "GET",
-
path: "/api/v1/terms",
-
description: "List all terms across taxonomies"
-
},
-
{
-
method: "GET",
-
path: "/api/v1/terms/:id",
-
description: "Get a specific term"
-
}
-
]
-
},
-
comments: {
-
title: "Comments",
-
description: "Manage post and page comments",
-
endpoints: [
-
{
-
method: "GET",
-
path: "/api/v1/posts/:post_id/comments",
-
description: "Get comments for a specific post"
-
},
-
{
-
method: "POST",
-
path: "/api/v1/posts/:post_id/comments",
-
description: "Create a new comment on a post",
-
params: {
-
content: "string (required)",
-
author_name: "string (required)",
-
author_email: "string (required)",
-
author_url: "string (optional)",
-
parent_id: "integer (optional, for replies)"
-
}
-
},
-
{
-
method: "PATCH",
-
path: "/api/v1/comments/:id/approve",
-
description: "Approve a comment",
-
requires_auth: true
-
},
-
{
-
method: "PATCH",
-
path: "/api/v1/comments/:id/spam",
-
description: "Mark a comment as spam",
-
requires_auth: true
-
}
-
]
-
},
-
media: {
-
title: "Media",
-
description: "Upload and manage media files",
-
endpoints: [
-
{
-
method: "GET",
-
path: "/api/v1/media",
-
description: "List all media files"
-
},
-
{
-
method: "POST",
-
path: "/api/v1/media",
-
description: "Upload a new media file",
-
requires_auth: true,
-
params: {
-
file: "multipart/form-data (required)"
-
}
-
},
-
{
-
method: "DELETE",
-
path: "/api/v1/media/:id",
-
description: "Delete a media file",
-
requires_auth: true
-
}
-
]
-
},
-
users: {
-
title: "Users",
-
description: "User management endpoints",
-
endpoints: [
-
{
-
method: "GET",
-
path: "/api/v1/users",
-
description: "List all users",
-
requires_auth: true
-
},
-
{
-
method: "GET",
-
path: "/api/v1/users/me",
-
description: "Get current authenticated user",
-
requires_auth: true
-
},
-
{
-
method: "PATCH",
-
path: "/api/v1/users/update_profile",
-
description: "Update current user profile",
-
requires_auth: true
-
}
-
]
-
},
-
ai_agents: {
-
title: "AI Agents",
-
description: "Execute AI agents for content generation and analysis",
-
endpoints: [
-
{
-
method: "GET",
-
path: "/api/v1/ai_agents",
-
description: "List all available AI agents"
-
},
-
{
-
method: "POST",
-
path: "/api/v1/ai_agents/execute/:agent_type",
-
description: "Execute an AI agent by type",
-
requires_auth: true,
-
params: {
-
agent_type: "string (content_summarizer, post_writer, comments_analyzer, seo_analyzer)",
-
input: "string (required - the content to process)"
-
},
-
response: {
-
result: "string (AI-generated content)",
-
agent: "object",
-
success: "boolean"
-
}
-
}
-
]
-
},
-
settings: {
-
title: "Settings",
-
description: "Site settings and configuration",
-
endpoints: [
-
{
-
method: "GET",
-
path: "/api/v1/settings",
-
description: "List all settings",
-
requires_auth: true
-
},
-
{
-
method: "GET",
-
path: "/api/v1/settings/get/:key",
-
description: "Get a specific setting value"
-
}
-
]
-
},
-
system: {
-
title: "System",
-
description: "System information and statistics",
-
endpoints: [
-
{
-
method: "GET",
-
path: "/api/v1/system/info",
-
description: "Get system information"
-
},
-
{
-
method: "GET",
-
path: "/api/v1/system/stats",
-
description: "Get site statistics",
-
response: {
-
posts_count: "integer",
-
pages_count: "integer",
-
users_count: "integer",
-
comments_count: "integer"
-
}
-
}
-
]
-
}
-
}
-
end
-
-
def load_plugin_docs
-
docs_path = Rails.root.join('docs', 'plugins')
-
docs = []
-
-
if Dir.exist?(docs_path)
-
Dir.glob(File.join(docs_path, '*.md')).each do |file|
-
filename = File.basename(file, '.md')
-
content = File.read(file)
-
-
# Extract title from markdown (first # heading)
-
title = content.match(/^#\s+(.+)$/m)&.[](1) || filename.titleize
-
-
docs << {
-
title: title,
-
filename: filename,
-
path: file,
-
content: content,
-
url: "/docs/plugins/#{filename}.md"
-
}
-
end
-
end
-
-
# Add core plugin documentation files
-
[
-
{ title: "Plugin Quick Start", path: Rails.root.join('docs', 'PLUGIN_QUICK_START.md') },
-
{ title: "Plugin MVC Architecture", path: Rails.root.join('docs', 'PLUGIN_MVC_ARCHITECTURE.md') },
-
{ title: "Plugin Developer Guide", path: Rails.root.join('docs', 'PLUGIN_DEVELOPER_GUIDE.md') }
-
].each do |doc|
-
if File.exist?(doc[:path])
-
content = File.read(doc[:path])
-
docs.unshift({
-
title: doc[:title],
-
filename: File.basename(doc[:path], '.md'),
-
path: doc[:path],
-
content: content,
-
url: "/docs/#{File.basename(doc[:path])}"
-
})
-
end
-
end
-
-
docs.sort_by { |d| d[:title] }
-
end
-
-
def load_theme_docs
-
docs_path = Rails.root.join('docs', 'themes')
-
docs = []
-
-
if Dir.exist?(docs_path)
-
Dir.glob(File.join(docs_path, '*.md')).each do |file|
-
filename = File.basename(file, '.md')
-
content = File.read(file)
-
-
# Extract title from markdown
-
title = content.match(/^#\s+(.+)$/m)&.[](1) || filename.titleize
-
-
docs << {
-
title: title,
-
filename: filename,
-
path: file,
-
content: content,
-
url: "/docs/themes/#{filename}.md"
-
}
-
end
-
end
-
-
docs.sort_by { |d| d[:title] }
-
end
-
-
def load_html_documentation(filename)
-
docs_path = Rails.root.join('docs', filename)
-
return nil unless File.exist?(docs_path)
-
-
begin
-
File.read(docs_path)
-
rescue => e
-
Rails.logger.error "Failed to load HTML documentation: #{e.message}"
-
nil
-
end
-
end
-
end
-
-
-
class Admin::BaseController < ApplicationController
-
before_action :authenticate_user!
-
before_action :ensure_admin_access
-
after_action :clear_flash_messages
-
-
layout 'admin'
-
-
private
-
-
def ensure_admin_access
-
unless current_user&.author? || current_user&.editor? || current_user&.administrator?
-
redirect_to root_path, alert: 'You do not have permission to access the admin area.'
-
end
-
end
-
-
def ensure_editor_access
-
unless current_user&.editor? || current_user&.administrator?
-
redirect_to admin_root_path, alert: 'You do not have permission to perform this action.'
-
end
-
end
-
-
def ensure_admin
-
unless current_user&.administrator?
-
redirect_to admin_root_path, alert: 'Only administrators can perform this action.'
-
end
-
end
-
-
def clear_flash_messages
-
# Clear flash messages after they've been displayed
-
# This prevents them from persisting across page loads
-
flash.clear
-
end
-
end
-
-
-
-
-
class Admin::BuilderController < Admin::BaseController
-
before_action :set_current_theme, only: [:index, :show, :create_version, :save_draft, :publish, :rollback, :preview, :sections, :update_section, :reorder_sections, :remove_section, :add_section, :add_block, :remove_block, :update_block, :update_theme_settings]
-
before_action :set_builder_theme, only: [:show, :save_draft, :publish, :rollback, :preview, :sections, :update_section, :reorder_sections, :remove_section, :add_section, :add_block, :remove_block, :update_block, :update_theme_settings]
-
before_action :ensure_editor_access, except: [:preview, :save_draft]
-
skip_before_action :verify_authenticity_token, only: [:preview]
-
-
# GET /admin/builder
-
def index
-
@current_theme_name = @current_theme&.name&.underscore || 'default'
-
@builder_theme = BuilderTheme.draft_for_theme(@current_theme_name) ||
-
BuilderTheme.current_for_theme(@current_theme_name)
-
-
if @builder_theme
-
redirect_to admin_builder_path(@builder_theme)
-
else
-
# Create initial version
-
@builder_theme = BuilderTheme.create_version(@current_theme_name, current_user)
-
redirect_to admin_builder_path(@builder_theme)
-
end
-
end
-
-
# GET /admin/builder/:id
-
def show
-
@current_theme_name = @current_theme&.name&.underscore || 'default'
-
@versions = BuilderTheme.for_theme(@current_theme_name).latest.limit(10)
-
@snapshots = BuilderThemeSnapshot.for_theme(@current_theme_name).latest.limit(10)
-
-
# Get available templates
-
@available_templates = get_available_templates
-
-
# Load current template (default to index)
-
@current_template_name = params[:template] || 'index'
-
-
# Get template data from ThemePreview (new system)
-
theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, @current_template_name)
-
-
# Clean up any duplicate sections first
-
theme_preview.cleanup_duplicates!
-
-
@current_page_sections = {}
-
@section_order = []
-
-
# Build sections hash from ThemePreviewSection records
-
Rails.logger.info "=== SHOW ACTION DEBUG ==="
-
Rails.logger.info "ThemePreview sections count: #{theme_preview.ordered_sections.count}"
-
-
# DEDUPLICATE SECTIONS: Group by section_id and take the latest one
-
sections_by_id = theme_preview.ordered_sections.group_by(&:section_id)
-
Rails.logger.info "Sections grouped by ID: #{sections_by_id.keys}"
-
-
sections_by_id.each do |section_id, sections|
-
# Take the latest section if there are duplicates
-
section = sections.max_by(&:updated_at)
-
Rails.logger.info "Using section #{section_id} with position #{section.position} (from #{sections.count} duplicates)"
-
-
@current_page_sections[section_id] = {
-
'type' => section.section_type,
-
'settings' => section.settings
-
}
-
@section_order << section_id
-
end
-
-
Rails.logger.info "Final section_order: #{@section_order}"
-
Rails.logger.info "Final current_page_sections keys: #{@current_page_sections.keys}"
-
Rails.logger.info "Section order has duplicates: #{@section_order.length != @section_order.uniq.length}"
-
Rails.logger.info "Section order unique count: #{@section_order.uniq.length}"
-
-
# FORCE DEDUPLICATION: Always deduplicate section order
-
Rails.logger.warn "FORCE DEDUPLICATION: Original order #{@section_order.length}, unique order #{@section_order.uniq.length}"
-
@section_order = @section_order.uniq
-
Rails.logger.info "FINAL CLEAN section_order: #{@section_order}"
-
-
@theme_schema = load_theme_schema
-
-
render layout: 'builder'
-
end
-
-
# POST /admin/builder/:id/create_version
-
def create_version
-
parent_version = @builder_theme
-
label = params[:label] || "Version #{Time.current.strftime('%Y%m%d_%H%M%S')}"
-
-
new_version = BuilderTheme.create_version(
-
@current_theme_name,
-
current_user,
-
parent_version,
-
label
-
)
-
-
# Copy all files from parent version
-
parent_version.builder_theme_files.each do |file|
-
new_version.builder_theme_files.create!(
-
path: file.path,
-
content: file.content,
-
checksum: file.checksum,
-
file_size: file.file_size,
-
tenant: new_version.tenant
-
)
-
end
-
-
respond_to do |format|
-
format.json { render json: { success: true, version_id: new_version.id, redirect_url: admin_builder_path(new_version) } }
-
format.html { redirect_to admin_builder_path(new_version), notice: 'New version created successfully!' }
-
end
-
end
-
-
# PATCH /admin/builder/:id/autosave
-
def autosave
-
sections_data = JSON.parse(params[:sections_data] || params.dig(:builder, :sections_data) || '{}')
-
settings_data = JSON.parse(params[:settings_data] || params.dig(:builder, :settings_data) || '{}')
-
template = params[:template] || params.dig(:builder, :template) || 'index'
-
-
begin
-
Rails.logger.info "Autosave triggered for template: #{template}"
-
-
# Get or create theme preview for this template
-
theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
-
-
# Update sections in ThemePreview (only if sections_data is provided)
-
if sections_data.present?
-
# Get existing sections for efficient updates
-
existing_sections = theme_preview.theme_preview_sections.index_by(&:section_id)
-
processed_section_ids = []
-
-
# Create or update sections from the data
-
sections_data.each_with_index do |(section_id, section_config), index|
-
processed_section_ids << section_id
-
-
if existing_sections[section_id]
-
# Update existing section
-
existing_sections[section_id].update!(
-
section_type: section_config['type'] || section_id,
-
settings: section_config['settings'] || {},
-
position: index
-
)
-
else
-
# Create new section
-
theme_preview.theme_preview_sections.create!(
-
section_id: section_id,
-
section_type: section_config['type'] || section_id,
-
settings: section_config['settings'] || {},
-
position: index
-
)
-
end
-
end
-
-
# Remove sections that are no longer in the data
-
sections_to_remove = existing_sections.keys - processed_section_ids
-
sections_to_remove.each do |section_id|
-
existing_sections[section_id]&.destroy!
-
end
-
end
-
-
# Update theme settings in ThemePreviewFile (only if settings_data is provided)
-
if settings_data.present?
-
ThemePreviewFile.update_template_content(
-
@builder_theme,
-
'settings_data',
-
settings_data
-
)
-
end
-
-
# Update individual files in ThemePreviewFile if provided
-
if params[:files].present?
-
params[:files].each do |file_path, content|
-
preview_file = @builder_theme.theme_preview_files.find_or_create_by(
-
file_path: file_path,
-
file_type: 'custom'
-
) do |file|
-
file.tenant = @builder_theme.tenant
-
end
-
preview_file.update!(content: content)
-
end
-
end
-
-
respond_to do |format|
-
format.json { render json: { success: true, message: 'Autosaved successfully!' } }
-
end
-
-
rescue => e
-
Rails.logger.error "Autosave failed: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
Rails.logger.error "Params: #{params.inspect}"
-
render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /admin/builder/:id/save_draft
-
def save_draft
-
Rails.logger.info "=== SAVE DRAFT CALLED ==="
-
Rails.logger.info "Params: #{params.inspect}"
-
Rails.logger.info "@builder_theme: #{@builder_theme.inspect}"
-
-
# Check if @builder_theme is nil
-
if @builder_theme.nil?
-
Rails.logger.error "ERROR: @builder_theme is nil!"
-
render json: { success: false, errors: ['Builder theme not found'] }, status: :not_found
-
return
-
end
-
-
sections_data = JSON.parse(params[:sections_data] || params.dig(:builder, :sections_data) || '{}')
-
settings_data = JSON.parse(params[:settings_data] || params.dig(:builder, :settings_data) || '{}')
-
template = params[:template] || params.dig(:builder, :template) || 'index'
-
-
begin
-
Rails.logger.info "Save draft params: #{params.inspect}"
-
Rails.logger.info "Sections data: #{sections_data.inspect}"
-
Rails.logger.info "Settings data: #{settings_data.inspect}"
-
-
# Get or create theme preview for this template
-
theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
-
-
# Update sections in ThemePreview
-
if sections_data.present?
-
# Get existing sections for efficient updates
-
existing_sections = theme_preview.theme_preview_sections.index_by(&:section_id)
-
processed_section_ids = []
-
-
# Create or update sections from the data
-
sections_data.each_with_index do |(section_id, section_config), index|
-
processed_section_ids << section_id
-
-
if existing_sections[section_id]
-
# Update existing section
-
existing_sections[section_id].update!(
-
section_type: section_config['type'] || section_id,
-
settings: section_config['settings'] || {},
-
position: index
-
)
-
else
-
# Create new section
-
theme_preview.theme_preview_sections.create!(
-
section_id: section_id,
-
section_type: section_config['type'] || section_id,
-
settings: section_config['settings'] || {},
-
position: index
-
)
-
end
-
end
-
-
# Remove sections that are no longer in the data
-
sections_to_remove = existing_sections.keys - processed_section_ids
-
sections_to_remove.each do |section_id|
-
existing_sections[section_id]&.destroy!
-
end
-
end
-
-
# Update theme settings in ThemePreviewFile
-
if settings_data.present?
-
ThemePreviewFile.update_template_content(
-
@builder_theme,
-
template,
-
settings_data
-
)
-
end
-
-
# Update individual files in ThemePreviewFile if provided
-
if params[:files].present?
-
params[:files].each do |file_path, content|
-
preview_file = @builder_theme.theme_preview_files.find_or_create_by(
-
file_path: file_path,
-
file_type: 'custom'
-
) do |file|
-
file.tenant = @builder_theme.tenant
-
end
-
preview_file.update!(content: content)
-
end
-
end
-
-
# Broadcast update to preview
-
broadcast_preview_update(@builder_theme)
-
-
respond_to do |format|
-
format.json { render json: { success: true, message: 'Draft saved to preview successfully!' } }
-
end
-
-
rescue => e
-
Rails.logger.error "Save draft failed: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
Rails.logger.error "Params: #{params.inspect}"
-
respond_to do |format|
-
format.json { render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH /admin/builder/:id/publish
-
def publish
-
template = params[:template] || params.dig(:builder, :template) || 'index'
-
-
begin
-
Rails.logger.info "Publishing template: #{template}"
-
-
# Get the theme preview
-
theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
-
-
# Ensure we have a published version to work with
-
published_version = @builder_theme.ensure_published_version!
-
-
# Copy sections from ThemePreview to PublishedThemeFile
-
sections_data = {}
-
section_order = []
-
-
theme_preview.ordered_sections.each do |section|
-
sections_data[section.section_id] = {
-
'type' => section.section_type,
-
'settings' => section.settings
-
}
-
section_order << section.section_id
-
end
-
-
# Create/update the template file in PublishedThemeFile
-
template_content = {
-
'name' => template.humanize,
-
'sections' => sections_data,
-
'order' => section_order
-
}
-
-
template_file = published_version.published_theme_files.find_or_create_by(
-
file_path: "templates/#{template}.json",
-
file_type: 'template'
-
)
-
-
template_file.update!(
-
content: template_content.to_json,
-
checksum: Digest::MD5.hexdigest(template_content.to_json)
-
)
-
-
# Copy theme settings if they exist in preview
-
settings_file = @builder_theme.theme_preview_files.find_by(
-
file_path: 'config/settings_data.json'
-
)
-
-
if settings_file
-
published_settings_file = published_version.published_theme_files.find_or_create_by(
-
file_path: 'config/settings_data.json',
-
file_type: 'config'
-
)
-
published_settings_file.update!(
-
content: settings_file.content,
-
checksum: Digest::MD5.hexdigest(settings_file.content)
-
)
-
end
-
-
# Copy any other custom files from preview to published
-
@builder_theme.theme_preview_files.where.not(
-
file_path: ['config/settings_data.json', "templates/#{template}.json"]
-
).each do |preview_file|
-
published_file = published_version.published_theme_files.find_or_create_by(
-
file_path: preview_file.file_path,
-
file_type: preview_file.file_type
-
)
-
published_file.update!(
-
content: preview_file.content,
-
checksum: Digest::MD5.hexdigest(preview_file.content)
-
)
-
end
-
-
# Mark the builder theme as published
-
@builder_theme.update!(published: true)
-
-
Rails.logger.info "Successfully published template: #{template}"
-
-
respond_to do |format|
-
format.json { render json: { success: true, message: 'Theme published successfully!' } }
-
end
-
-
rescue => e
-
Rails.logger.error "Publish failed: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
-
end
-
end
-
-
# POST /admin/builder/:id/rollback
-
def rollback
-
target_snapshot_id = params[:snapshot_id]
-
target_snapshot = BuilderThemeSnapshot.find(target_snapshot_id)
-
-
new_version = target_snapshot.rollback_to!(target_snapshot)
-
-
respond_to do |format|
-
format.json { render json: { success: true, version_id: new_version.id, redirect_url: admin_builder_path(new_version) } }
-
format.html { redirect_to admin_builder_path(new_version), notice: 'Rolled back successfully!' }
-
end
-
end
-
-
# GET /admin/builder/:id/preview
-
def preview
-
@builder_theme = BuilderTheme.find(params[:id])
-
@current_theme_name = @builder_theme.theme_name
-
-
# Ensure we have a published version to work with for base files (layout, assets)
-
published_version = @builder_theme.ensure_published_version!
-
-
template_type = params[:template] || 'index'
-
-
begin
-
# Use ThemePreviewRenderer for builder previews (uses ThemePreview data + PublishedThemeFile base files)
-
renderer = ThemePreviewRenderer.new(@builder_theme, template_type)
-
@preview_html = renderer.render
-
@assets = { css: '', js: '' } # Assets are embedded in HTML by ThemePreviewRenderer
-
rescue => e
-
Rails.logger.error "Builder preview rendering failed: #{e.message}"
-
@preview_html = "<div style='padding: 20px; color: red;'>Preview Error: #{e.message}</div>"
-
@assets = { css: '', js: '' }
-
end
-
-
# Render preview iframe
-
render 'preview', layout: false
-
end
-
-
# GET /admin/builder/:id/sections/:template
-
def sections
-
template_name = params[:template]
-
-
begin
-
# Get or create theme preview for this template
-
theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template_name)
-
-
# Get sections from ThemePreview
-
sections = theme_preview.ordered_sections.map do |section|
-
{
-
section_id: section.section_id,
-
section_type: section.section_type,
-
settings: section.settings,
-
position: section.position
-
}
-
end
-
-
render json: { success: true, sections: sections }
-
rescue => e
-
Rails.logger.error "Error getting sections: #{e.message}"
-
render json: { success: false, errors: [e.message] }, status: :unprocessable_entity
-
end
-
end
-
-
# GET /admin/builder/:id/available_sections
-
def available_sections
-
begin
-
# Get available sections from the BuilderTheme's theme directory
-
@builder_theme = BuilderTheme.find(params[:id])
-
-
# Get the theme name from the BuilderTheme
-
theme_name = @builder_theme.theme_name
-
-
# Get sections directory from the theme
-
manager = ThemesManager.new
-
sections_dir = File.join(manager.themes_path, theme_name, 'sections')
-
-
if Dir.exist?(sections_dir)
-
sections = []
-
processed_sections = Set.new
-
-
# First, process .liquid files
-
Dir.glob(File.join(sections_dir, '*.liquid')).each do |file_path|
-
section_name = File.basename(file_path, '.liquid')
-
section_name = section_name.to_s
-
-
# Skip if we've already processed this section
-
next if processed_sections.include?(section_name)
-
processed_sections.add(section_name)
-
-
# Try to read section schema for description
-
schema_path = File.join(sections_dir, "#{section_name}.json")
-
description = 'Section description'
-
category = 'General'
-
preview_image = nil
-
-
if File.exist?(schema_path)
-
begin
-
schema = JSON.parse(File.read(schema_path))
-
description = schema['description'] || 'Section description'
-
category = schema['category'] || 'General'
-
preview_image = schema['preview_image']
-
rescue JSON::ParserError
-
# Use default description if schema is invalid
-
end
-
end
-
-
# Extract context requests from schema
-
context_requests = {}
-
if File.exist?(schema_path)
-
begin
-
schema = JSON.parse(File.read(schema_path))
-
context_requests = schema['context_requests'] || {}
-
rescue JSON::ParserError
-
# Use empty context requests if schema is invalid
-
end
-
end
-
-
sections << {
-
id: section_name,
-
name: section_name.humanize,
-
description: description,
-
category: category,
-
preview_image: preview_image,
-
context_requests: context_requests
-
}
-
end
-
-
# Then, process standalone .json files (sections without .liquid files)
-
Dir.glob(File.join(sections_dir, '*.json')).each do |file_path|
-
section_name = File.basename(file_path, '.json')
-
section_name = section_name.to_s
-
-
# Skip if we've already processed this section
-
next if processed_sections.include?(section_name)
-
processed_sections.add(section_name)
-
-
begin
-
schema = JSON.parse(File.read(file_path))
-
sections << {
-
id: section_name,
-
name: section_name.humanize,
-
description: schema['description'] || 'Section description',
-
category: schema['category'] || 'General',
-
preview_image: schema['preview_image'],
-
context_requests: schema['context_requests'] || {}
-
}
-
rescue JSON::ParserError
-
# Skip invalid JSON files
-
next
-
end
-
end
-
-
# Add context data for sections that request it
-
sections_with_context = sections.map do |section|
-
if section[:context_requests].present?
-
section[:context_data] = get_context_data_for_section(section[:context_requests])
-
end
-
section
-
end
-
-
render json: { success: true, sections: sections_with_context }
-
else
-
render json: { success: false, errors: ['Sections directory not found'] }, status: :not_found
-
end
-
-
rescue => e
-
Rails.logger.error "Error loading available sections: #{e.message}"
-
render json: { success: false, errors: [e.message] }, status: :internal_server_error
-
end
-
end
-
-
# GET /admin/builder/:id/section_data
-
def section_data
-
begin
-
@builder_theme = BuilderTheme.find(params[:id])
-
section_type = params[:section_type]
-
-
# Get section schema
-
theme_name = @builder_theme.theme_name
-
manager = ThemesManager.new
-
schema_path = File.join(manager.themes_path, theme_name, 'sections', "#{section_type}.json")
-
-
schema = {}
-
if File.exist?(schema_path)
-
schema = JSON.parse(File.read(schema_path))
-
end
-
-
# Get context data for this section
-
context_data = get_context_data_for_section(schema['context_requests'] || {})
-
-
render json: {
-
success: true,
-
schema: schema,
-
context_data: context_data
-
}
-
-
rescue => e
-
Rails.logger.error "Error loading section data: #{e.message}"
-
render json: { success: false, errors: [e.message] }, status: :internal_server_error
-
end
-
end
-
-
def get_context_data_for_section(context_requests)
-
context_data = {}
-
-
context_requests.each do |key, request_config|
-
case key
-
when 'menus'
-
context_data[key] = get_menus_context
-
when 'pages'
-
context_data[key] = get_pages_context
-
when 'posts'
-
context_data[key] = get_posts_context
-
when 'categories'
-
context_data[key] = get_categories_context
-
when 'products'
-
context_data[key] = get_products_context
-
else
-
Rails.logger.warn "Unknown context request: #{key}"
-
end
-
end
-
-
context_data
-
end
-
-
def get_menus_context
-
# Return available menus for navigation
-
[
-
{
-
id: 1,
-
name: 'Main Navigation',
-
menu_items: [
-
{ id: 1, title: 'Home', url: '/', order: 1 },
-
{ id: 2, title: 'About', url: '/about', order: 2 },
-
{ id: 3, title: 'Services', url: '/services', order: 3 },
-
{ id: 4, title: 'Contact', url: '/contact', order: 4 }
-
]
-
},
-
{
-
id: 2,
-
name: 'Footer Links',
-
menu_items: [
-
{ id: 5, title: 'Privacy Policy', url: '/privacy', order: 1 },
-
{ id: 6, title: 'Terms of Service', url: '/terms', order: 2 },
-
{ id: 7, title: 'Support', url: '/support', order: 3 }
-
]
-
}
-
]
-
end
-
-
def get_pages_context
-
# Return available pages
-
[
-
{ id: 1, title: 'Home', slug: 'home', url: '/' },
-
{ id: 2, title: 'About Us', slug: 'about', url: '/about' },
-
{ id: 3, title: 'Services', slug: 'services', url: '/services' },
-
{ id: 4, title: 'Contact', slug: 'contact', url: '/contact' },
-
{ id: 5, title: 'Privacy Policy', slug: 'privacy', url: '/privacy' }
-
]
-
end
-
-
def get_posts_context
-
# Return recent posts
-
[
-
{ id: 1, title: 'Welcome to Our Blog', slug: 'welcome-blog', url: '/blog/welcome-blog' },
-
{ id: 2, title: 'Getting Started Guide', slug: 'getting-started', url: '/blog/getting-started' }
-
]
-
end
-
-
def get_categories_context
-
# Return post categories
-
[
-
{ id: 1, name: 'News', slug: 'news' },
-
{ id: 2, name: 'Tutorials', slug: 'tutorials' },
-
{ id: 3, name: 'Updates', slug: 'updates' }
-
]
-
end
-
-
def get_products_context
-
# Return sample products (for e-commerce sections)
-
[
-
{ id: 1, title: 'Sample Product 1', price: 29.99, url: '/products/sample-1' },
-
{ id: 2, title: 'Sample Product 2', price: 49.99, url: '/products/sample-2' }
-
]
-
end
-
-
# GET /admin/builder/:id/file/:file_path
-
def get_file
-
@builder_theme = BuilderTheme.find(params[:id])
-
file_path = params[:file_path]
-
-
file = @builder_theme.get_file(file_path)
-
-
if file
-
render json: {
-
success: true,
-
file: {
-
path: file.path,
-
content: file.content,
-
file_type: file.file_type,
-
schema: file.schema_data
-
}
-
}
-
else
-
render json: { success: false, error: 'File not found' }, status: :not_found
-
end
-
end
-
-
# PATCH /admin/builder/:id/file/:file_path
-
def update_file
-
@builder_theme = BuilderTheme.find(params[:id])
-
file_path = params[:file_path]
-
content = params[:content]
-
-
file = @builder_theme.update_file(file_path, content)
-
-
# Broadcast update to preview
-
broadcast_preview_update(@builder_theme)
-
-
render json: {
-
success: true,
-
file: {
-
path: file.path,
-
content: file.content,
-
checksum: file.checksum,
-
file_size: file.file_size
-
}
-
}
-
end
-
-
# GET /admin/builder/:id/render_preview
-
def render_preview
-
@builder_theme = BuilderTheme.find(params[:id])
-
template_type = params[:template] || 'index'
-
-
renderer = BuilderLiquidRenderer.new(@builder_theme)
-
preview_html = renderer.render_preview(template_type)
-
-
render json: {
-
success: true,
-
html: preview_html,
-
template: template_type
-
}
-
end
-
-
-
# POST /admin/builder/:id/add_section
-
def add_section
-
section_type = params[:section_type]
-
# Handle both string and ActionController::Parameters for settings
-
raw_settings = params[:settings] || params.dig(:builder, :settings) || {}
-
settings = case raw_settings
-
when String
-
JSON.parse(raw_settings)
-
when ActionController::Parameters
-
raw_settings.to_unsafe_h
-
else
-
raw_settings || {}
-
end
-
-
# Ensure settings is always a Hash (not nil) to satisfy the NOT NULL constraint
-
settings = {} if settings.nil?
-
template = params[:template] || 'index'
-
-
begin
-
Rails.logger.info "Add section params: #{params.inspect}"
-
Rails.logger.info "Section type: #{section_type}, Template: #{template}"
-
-
if section_type.blank?
-
return render json: { success: false, errors: ['Section type is required'] }, status: :bad_request
-
end
-
-
# Get or create theme preview (without auto-initialization to avoid clearing sections)
-
theme_preview = ThemePreview.find_or_create_by(
-
builder_theme: @builder_theme,
-
template_name: template
-
) do |preview|
-
preview.tenant = @builder_theme.tenant
-
end
-
-
# Generate a unique section ID
-
section_id = "#{section_type}_#{SecureRandom.hex(4)}"
-
-
# Create new section in ThemePreviewSection
-
section = theme_preview.theme_preview_sections.create!(
-
section_id: section_id,
-
section_type: section_type,
-
settings: settings,
-
position: theme_preview.theme_preview_sections.count
-
)
-
-
Rails.logger.info "Successfully added section #{section_id} (#{section_type}) to template #{template}"
-
-
respond_to do |format|
-
format.json { render json: {
-
success: true,
-
section: {
-
section_id: section.section_id,
-
section_type: section.section_type,
-
settings: section.settings,
-
position: section.position
-
}
-
} }
-
end
-
-
rescue => e
-
Rails.logger.error "Add section failed: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
Rails.logger.error "Params: #{params.inspect}"
-
render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/builder/:id/remove_section/:section_id
-
def remove_section
-
section_id = params[:section_id]
-
template = params[:template] || 'index'
-
-
begin
-
Rails.logger.info "Remove section params: #{params.inspect}"
-
Rails.logger.info "Section ID: #{section_id}, Template: #{template}"
-
-
if section_id.blank?
-
return render json: { success: false, errors: ['Section ID is required'] }, status: :bad_request
-
end
-
-
# Get or create theme preview
-
theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
-
-
# Find and remove the section from ThemePreviewSection
-
section = theme_preview.theme_preview_sections.find_by(section_id: section_id)
-
if section
-
section.destroy!
-
Rails.logger.info "Successfully removed section #{section_id} from template #{template}"
-
-
render json: { success: true, message: 'Section removed successfully!' }
-
else
-
Rails.logger.warn "Section #{section_id} not found in template #{template}"
-
render json: { success: false, errors: ['Section not found'] }, status: :not_found
-
end
-
-
rescue => e
-
Rails.logger.error "Remove section failed: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
Rails.logger.error "Params: #{params.inspect}"
-
render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
-
end
-
end
-
-
# POST /admin/builder/:id/add_block
-
def add_block
-
section_id = params[:section_id]
-
block_type = params[:block_type]
-
block_id = params[:block_id]
-
settings = case params[:settings]
-
when String
-
JSON.parse(params[:settings])
-
when ActionController::Parameters
-
params[:settings].to_unsafe_h
-
else
-
params[:settings] || {}
-
end
-
template = params[:template] || 'index'
-
-
begin
-
Rails.logger.info "Add block params: #{params.inspect}"
-
Rails.logger.info "Section ID: #{section_id}, Block Type: #{block_type}, Block ID: #{block_id}"
-
-
if section_id.blank? || block_type.blank? || block_id.blank?
-
return render json: { success: false, errors: ['Section ID, block type, and block ID are required'] }, status: :bad_request
-
end
-
-
# Get or create theme preview
-
theme_preview = ThemePreview.find_or_create_by(
-
builder_theme: @builder_theme,
-
template_name: template
-
) do |preview|
-
preview.tenant = @builder_theme.tenant
-
end
-
-
# Find the section
-
section = theme_preview.theme_preview_sections.find_by(section_id: section_id)
-
if !section
-
return render json: { success: false, errors: ['Section not found'] }, status: :not_found
-
end
-
-
# Create new block
-
block = section.theme_preview_blocks.create!(
-
block_id: block_id,
-
block_type: block_type,
-
settings: settings,
-
position: section.theme_preview_blocks.count
-
)
-
-
Rails.logger.info "Successfully added block #{block_id} (#{block_type}) to section #{section_id}"
-
-
respond_to do |format|
-
format.json { render json: {
-
success: true,
-
block: {
-
block_id: block.block_id,
-
block_type: block.block_type,
-
settings: block.settings,
-
position: block.position
-
}
-
} }
-
end
-
-
rescue => e
-
Rails.logger.error "Add block failed: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
Rails.logger.error "Params: #{params.inspect}"
-
render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/builder/:id/remove_block/:block_id
-
def remove_block
-
block_id = params[:block_id]
-
section_id = params[:section_id]
-
template = params[:template] || 'index'
-
-
begin
-
Rails.logger.info "Remove block params: #{params.inspect}"
-
Rails.logger.info "Block ID: #{block_id}, Section ID: #{section_id}"
-
-
if block_id.blank?
-
return render json: { success: false, errors: ['Block ID is required'] }, status: :bad_request
-
end
-
-
# Get theme preview
-
theme_preview = ThemePreview.find_or_create_by(
-
builder_theme: @builder_theme,
-
template_name: template
-
) do |preview|
-
preview.tenant = @builder_theme.tenant
-
end
-
-
# Find the section and block
-
section = theme_preview.theme_preview_sections.find_by(section_id: section_id)
-
if !section
-
return render json: { success: false, errors: ['Section not found'] }, status: :not_found
-
end
-
-
block = section.theme_preview_blocks.find_by(block_id: block_id)
-
if block
-
block.destroy!
-
Rails.logger.info "Successfully removed block #{block_id} from section #{section_id}"
-
-
render json: { success: true, message: 'Block removed successfully!' }
-
else
-
Rails.logger.warn "Block #{block_id} not found in section #{section_id}"
-
render json: { success: false, errors: ['Block not found'] }, status: :not_found
-
end
-
-
rescue => e
-
Rails.logger.error "Remove block failed: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
Rails.logger.error "Params: #{params.inspect}"
-
render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /admin/builder/:id/update_block/:block_id
-
def update_block
-
block_id = params[:block_id]
-
section_id = params[:section_id]
-
template = params[:template] || 'index'
-
settings = case params[:settings]
-
when String
-
JSON.parse(params[:settings])
-
when ActionController::Parameters
-
params[:settings].to_unsafe_h
-
else
-
params[:settings] || {}
-
end
-
-
begin
-
Rails.logger.info "Update block params: #{params.inspect}"
-
Rails.logger.info "Block ID: #{block_id}, Section ID: #{section_id}, Settings: #{settings.inspect}"
-
-
if block_id.blank?
-
return render json: { success: false, errors: ['Block ID is required'] }, status: :bad_request
-
end
-
-
# Get theme preview
-
theme_preview = ThemePreview.find_or_create_by(
-
builder_theme: @builder_theme,
-
template_name: template
-
) do |preview|
-
preview.tenant = @builder_theme.tenant
-
end
-
-
# Find the section and block
-
section = theme_preview.theme_preview_sections.find_by(section_id: section_id)
-
if !section
-
return render json: { success: false, errors: ['Section not found'] }, status: :not_found
-
end
-
-
block = section.theme_preview_blocks.find_by(block_id: block_id)
-
if !block
-
return render json: { success: false, errors: ['Block not found'] }, status: :not_found
-
end
-
-
# Update the block settings
-
block.update!(settings: settings)
-
-
Rails.logger.info "Successfully updated block #{block_id} for section #{section_id}"
-
-
render json: {
-
success: true,
-
message: 'Block updated successfully!',
-
block_id: block_id,
-
updated_settings: settings
-
}
-
-
rescue JSON::ParserError => e
-
Rails.logger.error "JSON parsing error in update_block: #{e.message}"
-
render json: { success: false, errors: ['Invalid JSON in settings'] }, status: :bad_request
-
rescue => e
-
Rails.logger.error "Update block failed: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
Rails.logger.error "Params: #{params.inspect}"
-
render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /admin/builder/:id/update_theme_settings
-
def update_theme_settings
-
template = params[:template] || 'index'
-
settings = case params[:settings]
-
when String
-
JSON.parse(params[:settings])
-
when ActionController::Parameters
-
params[:settings].to_unsafe_h
-
else
-
params[:settings] || {}
-
end
-
-
begin
-
Rails.logger.info "Update theme settings params: #{params.inspect}"
-
Rails.logger.info "Template: #{template}, Settings: #{settings.inspect}"
-
-
# Get or create theme preview
-
theme_preview = ThemePreview.find_or_create_by(
-
builder_theme: @builder_theme,
-
template_name: template
-
) do |preview|
-
preview.tenant = @builder_theme.tenant
-
end
-
-
# Update theme settings
-
theme_preview.update!(theme_settings_json: settings)
-
-
Rails.logger.info "Successfully updated theme settings for template #{template}"
-
-
render json: {
-
success: true,
-
message: 'Theme settings updated successfully!',
-
template: template,
-
updated_settings: settings
-
}
-
-
rescue JSON::ParserError => e
-
Rails.logger.error "JSON parsing error in update_theme_settings: #{e.message}"
-
render json: { success: false, errors: ['Invalid JSON in settings'] }, status: :bad_request
-
rescue => e
-
Rails.logger.error "Update theme settings failed: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
Rails.logger.error "Params: #{params.inspect}"
-
render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /admin/builder/:id/update_section/:section_id
-
def update_section
-
section_id = params[:section_id]
-
template = params[:template] || params.dig(:builder, :template) || 'index'
-
# params[:settings] can arrive as a Hash (from JSON) or a String
-
# Handle both direct params and nested builder params
-
raw_settings = params[:settings] || params.dig(:builder, :settings)
-
settings = case raw_settings
-
when String
-
JSON.parse(raw_settings)
-
when ActionController::Parameters
-
raw_settings.to_unsafe_h
-
else
-
raw_settings || {}
-
end
-
-
begin
-
Rails.logger.info "Update section params: #{params.inspect}"
-
Rails.logger.info "Section ID: #{section_id}, Template: #{template}, Settings: #{settings.inspect}"
-
-
# Validate section_id is present
-
if section_id.blank?
-
return render json: { success: false, errors: ['Section ID is required'] }, status: :bad_request
-
end
-
-
# Use ThemePreview for builder previews (separate from published themes)
-
theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
-
-
# Update the section settings
-
theme_preview.update_section_settings(section_id, settings)
-
-
Rails.logger.info "Successfully updated section #{section_id} for template #{template}"
-
-
render json: {
-
success: true,
-
message: 'Section updated successfully!',
-
section_id: section_id,
-
updated_settings: settings
-
}
-
-
rescue JSON::ParserError => e
-
Rails.logger.error "JSON parsing error in update_section: #{e.message}"
-
render json: { success: false, errors: ['Invalid JSON in settings'] }, status: :bad_request
-
rescue => e
-
Rails.logger.error "Update section failed: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
Rails.logger.error "Params: #{params.inspect}"
-
render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /admin/builder/:id/reorder_sections
-
def reorder_sections
-
begin
-
# Handle both array and JSON string parameters
-
raw_section_ids = params[:section_ids] || params.dig(:builder, :section_ids) || []
-
-
if raw_section_ids.is_a?(String)
-
section_ids = JSON.parse(raw_section_ids)
-
else
-
section_ids = raw_section_ids
-
end
-
-
template = params[:template] || params.dig(:builder, :template) || 'index'
-
-
Rails.logger.info "=== REORDER SECTIONS DEBUG ==="
-
Rails.logger.info "Raw section IDs: #{raw_section_ids.inspect}"
-
Rails.logger.info "Processed section IDs: #{section_ids.inspect}"
-
Rails.logger.info "Template: #{template}"
-
-
# Validate that we have section IDs
-
if section_ids.blank?
-
return render json: { success: false, errors: ['No section IDs provided'] }, status: :bad_request
-
end
-
-
# Use ThemePreview for builder previews (separate from published themes)
-
theme_preview = ThemePreview.find_or_create_for_builder(@builder_theme, template)
-
-
# Validate that all section IDs exist in the preview
-
existing_section_ids = theme_preview.theme_preview_sections.pluck(:section_id)
-
invalid_section_ids = section_ids - existing_section_ids
-
-
if invalid_section_ids.any?
-
Rails.logger.error "Invalid section IDs provided: #{invalid_section_ids.inspect}"
-
Rails.logger.error "Existing section IDs: #{existing_section_ids.inspect}"
-
return render json: {
-
success: false,
-
errors: ["Invalid section IDs: #{invalid_section_ids.join(', ')}"]
-
}, status: :bad_request
-
end
-
-
# Update the section order
-
theme_preview.update_section_order(section_ids)
-
-
Rails.logger.info "Successfully reordered sections for template: #{template}"
-
-
respond_to do |format|
-
format.json { render json: {
-
success: true,
-
message: 'Sections reordered successfully!',
-
section_ids: section_ids
-
} }
-
end
-
-
rescue JSON::ParserError => e
-
Rails.logger.error "JSON parsing error in reorder_sections: #{e.message}"
-
render json: { success: false, errors: ['Invalid JSON in section IDs'] }, status: :bad_request
-
rescue => e
-
Rails.logger.error "Reorder sections failed: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
render json: { success: false, errors: [e.message], backtrace: e.backtrace.first(5) }, status: :unprocessable_entity
-
end
-
end
-
-
def format_section_data(section)
-
{
-
id: section.section_id,
-
type: section.section_type,
-
settings: section.settings,
-
position: section.position,
-
display_name: section.display_name,
-
description: section.description
-
}
-
end
-
-
# GET /admin/builder/:id/versions
-
def versions
-
@builder_theme = BuilderTheme.find(params[:id])
-
@versions = BuilderTheme.for_theme(@builder_theme.theme_name).includes(:user).latest
-
-
render json: {
-
versions: @versions.map do |version|
-
{
-
id: version.id,
-
label: version.label,
-
created_at: version.created_at,
-
created_by: version.user.email,
-
published: version.published?,
-
version_number: version.version_number
-
}
-
end
-
}
-
end
-
-
# GET /admin/builder/:id/snapshots
-
def snapshots
-
@builder_theme = BuilderTheme.find(params[:id])
-
@snapshots = BuilderThemeSnapshot.for_theme(@builder_theme.theme_name).includes(:user).latest
-
-
render json: {
-
snapshots: @snapshots.map do |snapshot|
-
{
-
id: snapshot.id,
-
created_at: snapshot.created_at,
-
created_by: snapshot.user.email,
-
checksum: snapshot.checksum
-
}
-
end
-
}
-
end
-
-
def set_current_theme
-
# Allow editing any theme, not just active ones
-
if params[:theme_id].present?
-
@current_theme = Theme.find(params[:theme_id])
-
elsif params[:theme_name].present?
-
@current_theme = Theme.where("LOWER(name) = ?", params[:theme_name].downcase).first
-
else
-
# Fallback to active theme
-
@current_theme = Theme.active.first
-
end
-
end
-
-
def preview_context
-
{
-
current_user: current_user,
-
request: request
-
}
-
end
-
-
def set_builder_theme
-
Rails.logger.info "Looking for BuilderTheme with ID: #{params[:id]}"
-
@builder_theme = BuilderTheme.find_by(id: params[:id])
-
-
unless @builder_theme
-
Rails.logger.error "BuilderTheme not found with ID: #{params[:id]}"
-
Rails.logger.info "Available BuilderThemes: #{BuilderTheme.pluck(:id, :label)}"
-
render json: { success: false, errors: ['Builder theme not found'] }, status: :not_found
-
return
-
end
-
-
Rails.logger.info "Found BuilderTheme: #{@builder_theme.inspect}"
-
end
-
-
def get_available_templates
-
# Use ThemesManager to get routes from the selected theme
-
manager = ThemesManager.new
-
-
begin
-
# Get routes from the current theme (active or selected)
-
theme_name = @current_theme&.name&.underscore || 'default'
-
routes_data = manager.get_file("config/routes.json", theme_name)
-
routes = routes_data['routes'] || []
-
-
# Convert to the format expected by the view
-
routes.map do |route|
-
{
-
'name' => route['name'] || route['template'].humanize,
-
'template' => route['template'],
-
'path' => route['pattern']
-
}
-
end
-
rescue => e
-
Rails.logger.error "Error loading routes from ThemesManager: #{e.message}"
-
fallback_templates
-
end
-
end
-
-
private
-
-
def get_context_data_for_section(context_requests)
-
context_data = {}
-
-
context_requests.each do |key, request_config|
-
case key
-
when 'menus'
-
context_data[key] = get_menus_context
-
when 'pages'
-
context_data[key] = get_pages_context
-
when 'posts'
-
context_data[key] = get_posts_context
-
when 'categories'
-
context_data[key] = get_categories_context
-
when 'products'
-
context_data[key] = get_products_context
-
else
-
Rails.logger.warn "Unknown context request: #{key}"
-
end
-
end
-
-
context_data
-
end
-
-
def get_menus_context
-
# Return available menus for navigation
-
[
-
{
-
id: 1,
-
name: 'Main Navigation',
-
menu_items: [
-
{ id: 1, title: 'Home', url: '/', order: 1 },
-
{ id: 2, title: 'About', url: '/about', order: 2 },
-
{ id: 3, title: 'Services', url: '/services', order: 3 },
-
{ id: 4, title: 'Contact', url: '/contact', order: 4 }
-
]
-
},
-
{
-
id: 2,
-
name: 'Footer Links',
-
menu_items: [
-
{ id: 5, title: 'Privacy Policy', url: '/privacy', order: 1 },
-
{ id: 6, title: 'Terms of Service', url: '/terms', order: 2 },
-
{ id: 7, title: 'Support', url: '/support', order: 3 }
-
]
-
}
-
]
-
end
-
-
def get_pages_context
-
# Return available pages
-
[
-
{ id: 1, title: 'Home', slug: 'home', url: '/' },
-
{ id: 2, title: 'About Us', slug: 'about', url: '/about' },
-
{ id: 3, title: 'Services', slug: 'services', url: '/services' },
-
{ id: 4, title: 'Contact', slug: 'contact', url: '/contact' },
-
{ id: 5, title: 'Privacy Policy', slug: 'privacy', url: '/privacy' }
-
]
-
end
-
-
def get_posts_context
-
# Return recent posts
-
[
-
{ id: 1, title: 'Welcome to Our Blog', slug: 'welcome-blog', url: '/blog/welcome-blog' },
-
{ id: 2, title: 'Getting Started Guide', slug: 'getting-started', url: '/blog/getting-started' }
-
]
-
end
-
-
def get_categories_context
-
# Return post categories
-
[
-
{ id: 1, name: 'News', slug: 'news' },
-
{ id: 2, name: 'Tutorials', slug: 'tutorials' },
-
{ id: 3, name: 'Updates', slug: 'updates' }
-
]
-
end
-
-
def get_products_context
-
# Return sample products (for e-commerce sections)
-
[
-
{ id: 1, title: 'Sample Product 1', price: 29.99, url: '/products/sample-1' },
-
{ id: 2, title: 'Sample Product 2', price: 49.99, url: '/products/sample-2' }
-
]
-
end
-
-
def format_section_data(section)
-
{
-
id: section.section_id,
-
type: section.section_type,
-
settings: section.settings,
-
position: section.position,
-
created_at: section.created_at,
-
updated_at: section.updated_at
-
}
-
end
-
-
def set_current_theme
-
# Allow editing any theme, not just active ones
-
if params[:theme_id].present?
-
@current_theme = Theme.find(params[:theme_id])
-
elsif params[:theme_name].present?
-
@current_theme = Theme.where("LOWER(name) = ?", params[:theme_name].downcase).first
-
else
-
@current_theme = Theme.active.first || Theme.first
-
end
-
end
-
-
def set_builder_theme
-
@builder_theme = BuilderTheme.find(params[:id])
-
end
-
-
def fallback_templates
-
# Fallback to default templates
-
[
-
{ 'name' => 'Home', 'template' => 'index', 'path' => '/' },
-
{ 'name' => 'Blog', 'template' => 'blog', 'path' => '/blog' },
-
{ 'name' => 'Post', 'template' => 'post', 'path' => '/post' },
-
{ 'name' => 'Page', 'template' => 'page', 'path' => '/page' },
-
{ 'name' => 'Search', 'template' => 'search', 'path' => '/search' },
-
{ 'name' => '404', 'template' => '404', 'path' => '/404' }
-
]
-
end
-
-
def load_theme_schema
-
# Load theme settings schema from config/settings_schema.json using ThemesManager
-
manager = ThemesManager.new
-
settings_content = manager.get_file('config/settings_schema.json')
-
return [] unless settings_content
-
-
begin
-
JSON.parse(settings_content)
-
rescue JSON::ParserError
-
[]
-
end
-
end
-
-
def broadcast_preview_update(builder_theme)
-
# Broadcast to ActionCable channel for live preview updates
-
ActionCable.server.broadcast(
-
"builder_preview_#{builder_theme.id}",
-
{
-
type: 'preview_update',
-
theme_id: builder_theme.id,
-
timestamp: Time.current.to_i
-
}
-
)
-
end
-
-
def builder_theme_params
-
params.require(:builder_theme).permit(:label, :summary)
-
end
-
end
-
class Admin::BulkOptimizationController < Admin::BaseController
-
before_action :ensure_admin
-
-
# GET /admin/media/bulk_optimization
-
def index
-
@stats = calculate_optimization_stats
-
@unoptimized_count = count_unoptimized_images
-
-
# Load compression level information
-
compression_level_name = SiteSetting.get('image_compression_level', 'lossy')
-
compression_config = ImageOptimizationService.available_compression_levels[compression_level_name] || ImageOptimizationService.available_compression_levels['lossy']
-
-
@compression_level_name = compression_config[:name]
-
@compression_description = compression_config[:description]
-
@expected_savings = compression_config[:expected_savings]
-
@recommended_for = compression_config[:recommended_for]
-
end
-
-
# POST /admin/media/bulk_optimize
-
def start_bulk_optimization
-
# Get all unoptimized images
-
unoptimized_uploads = get_unoptimized_uploads
-
-
if unoptimized_uploads.empty?
-
render json: {
-
success: false,
-
message: 'No unoptimized images found'
-
}
-
return
-
end
-
-
# Queue optimization jobs
-
job_count = 0
-
unoptimized_uploads.find_each do |upload|
-
upload.media.each do |medium|
-
OptimizeImageJob.perform_later(medium_id: medium.id)
-
job_count += 1
-
end
-
end
-
-
# Store job tracking info
-
Rails.cache.write('bulk_optimization_jobs', job_count, expires_in: 1.hour)
-
Rails.cache.write('bulk_optimization_started', Time.current, expires_in: 1.hour)
-
-
render json: {
-
success: true,
-
message: "Queued #{job_count} images for optimization",
-
total_jobs: job_count
-
}
-
end
-
-
# GET /admin/media/bulk_optimize_status
-
def status
-
total_jobs = Rails.cache.read('bulk_optimization_jobs') || 0
-
started_at = Rails.cache.read('bulk_optimization_started')
-
-
if total_jobs == 0
-
render json: {
-
percentage: 100,
-
message: 'No optimization jobs running',
-
completed: true
-
}
-
return
-
end
-
-
# Calculate progress based on completed optimizations
-
completed_count = count_optimized_images
-
percentage = total_jobs > 0 ? ((completed_count.to_f / total_jobs) * 100).round(1) : 0
-
-
message = if percentage >= 100
-
'Optimization complete!'
-
elsif percentage > 0
-
"Optimizing images... #{completed_count}/#{total_jobs} completed"
-
else
-
'Starting optimization...'
-
end
-
-
render json: {
-
percentage: percentage,
-
message: message,
-
completed: percentage >= 100,
-
completed_count: completed_count,
-
total_jobs: total_jobs
-
}
-
end
-
-
# POST /admin/media/regenerate_variants
-
def regenerate_variants
-
upload_id = params[:upload_id]
-
-
if upload_id
-
# Regenerate variants for specific upload
-
upload = Upload.find(upload_id)
-
medium = upload.media.first
-
-
if medium
-
OptimizeImageJob.perform_later(medium_id: medium.id)
-
render json: {
-
success: true,
-
message: 'Variants regeneration queued'
-
}
-
else
-
render json: {
-
success: false,
-
message: 'No medium found for this upload'
-
}
-
end
-
else
-
# Regenerate variants for all images
-
optimized_uploads = Upload.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
-
.where.not(variants: [nil, {}])
-
-
job_count = 0
-
optimized_uploads.find_each do |upload|
-
upload.media.each do |medium|
-
OptimizeImageJob.perform_later(medium_id: medium.id)
-
job_count += 1
-
end
-
end
-
-
render json: {
-
success: true,
-
message: "Queued #{job_count} images for variant regeneration"
-
}
-
end
-
end
-
-
# DELETE /admin/media/clear_variants
-
def clear_variants
-
upload_id = params[:upload_id]
-
-
if upload_id
-
# Clear variants for specific upload
-
upload = Upload.find(upload_id)
-
clear_upload_variants(upload)
-
-
render json: {
-
success: true,
-
message: 'Variants cleared for this image'
-
}
-
else
-
# Clear all variants (dangerous operation)
-
render json: {
-
success: false,
-
message: 'Bulk variant clearing not implemented for safety'
-
}
-
end
-
end
-
-
# GET /admin/media/optimization_report
-
def report
-
@stats = calculate_detailed_stats
-
@recent_optimizations = get_recent_optimizations
-
@space_saved = calculate_space_saved
-
end
-
-
private
-
-
def calculate_optimization_stats
-
total_images = Upload.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
-
.count
-
-
optimized_images = Upload.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
-
.where.not(variants: [nil, {}])
-
.count
-
-
webp_variants = Upload.where("variants LIKE ?", '%webp%').count
-
avif_variants = Upload.where("variants LIKE ?", '%avif%').count
-
-
{
-
total_images: total_images,
-
optimized_images: optimized_images,
-
unoptimized_images: total_images - optimized_images,
-
webp_variants: webp_variants,
-
avif_variants: avif_variants,
-
optimization_percentage: total_images > 0 ? ((optimized_images.to_f / total_images) * 100).round(1) : 0
-
}
-
end
-
-
def count_unoptimized_images
-
Upload.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
-
.where(variants: [nil, {}])
-
.count
-
end
-
-
def count_optimized_images
-
Upload.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
-
.where.not(variants: [nil, {}])
-
.count
-
end
-
-
def get_unoptimized_uploads
-
Upload.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
-
.where(variants: [nil, {}])
-
end
-
-
def clear_upload_variants(upload)
-
return unless upload.variants
-
-
# Delete variant blobs
-
upload.variants.each do |format, variant_data|
-
blob_id = variant_data['blob_id']
-
blob = ActiveStorage::Blob.find_by(id: blob_id)
-
blob&.purge
-
end
-
-
# Clear variants from upload
-
upload.update!(variants: {})
-
end
-
-
def calculate_detailed_stats
-
stats = calculate_optimization_stats
-
-
# Add more detailed statistics
-
stats.merge({
-
responsive_variants: count_responsive_variants,
-
average_file_size: calculate_average_file_size,
-
total_storage_used: calculate_total_storage_used
-
})
-
end
-
-
def count_responsive_variants
-
Upload.where("variants LIKE ?", '%_w%').count
-
end
-
-
def calculate_average_file_size
-
Upload.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
-
.average('active_storage_blobs.byte_size')
-
&.round(2) || 0
-
end
-
-
def calculate_total_storage_used
-
Upload.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
-
.sum('active_storage_blobs.byte_size')
-
&.round(2) || 0
-
end
-
-
def calculate_space_saved
-
# Estimate space saved based on optimization
-
total_images = calculate_optimization_stats[:total_images]
-
optimized_images = calculate_optimization_stats[:optimized_images]
-
-
# Assume 30% average savings per optimized image
-
estimated_savings = (optimized_images * 0.3).round(2)
-
-
{
-
estimated_mb_saved: estimated_savings,
-
estimated_percentage: total_images > 0 ? ((estimated_savings / total_images) * 100).round(1) : 0
-
}
-
end
-
-
def get_recent_optimizations
-
Upload.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] })
-
.where.not(variants: [nil, {}])
-
.order(updated_at: :desc)
-
.limit(10)
-
end
-
end
-
class Admin::CacheController < Admin::BaseController
-
def index
-
# Load Redis settings from system configuration
-
@cache_enabled = SiteSetting.get('redis_enabled', Rails.cache.is_a?(ActiveSupport::Cache::RedisCacheStore))
-
@redis_url = SiteSetting.get('redis_url', ENV['REDIS_URL'] || 'redis://localhost:6379/0')
-
@cache_url = SiteSetting.get('redis_cache_url', ENV['REDIS_CACHE_URL'] || ENV['REDIS_URL'] || 'redis://localhost:6379/1')
-
@session_url = SiteSetting.get('redis_session_url', ENV['REDIS_SESSION_URL'] || ENV['REDIS_URL'] || 'redis://localhost:6379/2')
-
@timeout = SiteSetting.get('redis_timeout', 5)
-
@connect_timeout = SiteSetting.get('redis_connect_timeout', 5)
-
@reconnect_attempts = SiteSetting.get('redis_reconnect_attempts', 3)
-
@reconnect_delay = SiteSetting.get('redis_reconnect_delay', 0.5)
-
@reconnect_delay_max = SiteSetting.get('redis_reconnect_delay_max', 2.0)
-
@cache_expires_in = (SiteSetting.get('redis_cache_expires_in', 1.hour.to_i) / 1.hour.to_i)
-
@session_expires_in = (SiteSetting.get('redis_session_expires_in', 24.hours.to_i) / 1.hour.to_i)
-
-
@redis_configured = defined?(Redis)
-
-
# Get Redis connection info if available
-
begin
-
redis_url = @redis_url
-
if defined?(Redis) && redis_url.present?
-
redis = Redis.new(url: redis_url)
-
@redis_info = redis.info
-
@redis_connected = true
-
@redis_configured = true
-
-
# Get additional stats
-
@redis_stats = {
-
db_size: redis.dbsize,
-
memory_usage: @redis_info['used_memory_human'],
-
connected_clients: @redis_info['connected_clients'],
-
version: @redis_info['redis_version'],
-
uptime: @redis_info['uptime_in_seconds']
-
}
-
-
# Calculate hit rate if available
-
if @redis_info['keyspace_hits'] && @redis_info['keyspace_misses']
-
total_requests = @redis_info['keyspace_hits'].to_f + @redis_info['keyspace_misses'].to_f
-
@redis_stats[:hit_rate] = total_requests > 0 ? (@redis_info['keyspace_hits'].to_f / total_requests) : 0
-
else
-
@redis_stats[:hit_rate] = 0
-
end
-
-
redis.quit
-
else
-
@redis_connected = false
-
@redis_configured = false
-
@redis_info = {}
-
@redis_stats = {}
-
end
-
rescue => e
-
@redis_connected = false
-
@redis_configured = false
-
@redis_info = {}
-
@redis_stats = {}
-
@redis_error = e.message
-
end
-
end
-
-
def update
-
redis_params = params.permit(
-
:enabled, :url, :cache_url, :session_url, :timeout, :connect_timeout,
-
:reconnect_attempts, :reconnect_delay, :reconnect_delay_max,
-
:cache_expires_in, :session_expires_in
-
)
-
-
begin
-
# Convert enabled checkbox to boolean
-
enabled = redis_params[:enabled] == '1'
-
-
# Save Redis settings to SiteSetting
-
SiteSetting.set('redis_enabled', enabled, 'general')
-
SiteSetting.set('redis_url', redis_params[:url], 'general')
-
SiteSetting.set('redis_cache_url', redis_params[:cache_url], 'general')
-
SiteSetting.set('redis_session_url', redis_params[:session_url], 'general')
-
SiteSetting.set('redis_timeout', redis_params[:timeout].to_i, 'general')
-
SiteSetting.set('redis_connect_timeout', redis_params[:connect_timeout].to_i, 'general')
-
SiteSetting.set('redis_reconnect_attempts', redis_params[:reconnect_attempts].to_i, 'general')
-
SiteSetting.set('redis_reconnect_delay', redis_params[:reconnect_delay].to_f, 'general')
-
SiteSetting.set('redis_reconnect_delay_max', redis_params[:reconnect_delay_max].to_f, 'general')
-
SiteSetting.set('redis_cache_expires_in', redis_params[:cache_expires_in].to_i.hours.to_i, 'general')
-
SiteSetting.set('redis_session_expires_in', redis_params[:session_expires_in].to_i.hours.to_i, 'general')
-
-
# Test the connection with new settings
-
if enabled && redis_params[:url].present?
-
begin
-
redis = Redis.new(url: redis_params[:url])
-
redis.ping
-
redis.quit
-
message = "Redis settings updated and connection tested successfully! Note: Some changes may require application restart."
-
rescue => e
-
message = "Redis settings saved but connection test failed: #{e.message}"
-
end
-
else
-
message = "Redis settings updated successfully!"
-
end
-
-
if request.xhr?
-
render json: { success: true, message: message }
-
else
-
flash[:notice] = message
-
redirect_to admin_cache_path
-
end
-
rescue => e
-
error_message = "Failed to update Redis settings: #{e.message}"
-
if request.xhr?
-
render json: { success: false, message: error_message }
-
else
-
flash[:alert] = error_message
-
redirect_to admin_cache_path
-
end
-
end
-
end
-
-
def test_connection
-
begin
-
redis_url = SiteSetting.get('redis_url', ENV['REDIS_URL'] || 'redis://localhost:6379/0')
-
-
redis = Redis.new(url: redis_url)
-
info = redis.info
-
redis.quit
-
-
message = "Redis connection test successful!"
-
if request.xhr?
-
render json: { success: true, message: message }
-
else
-
flash[:notice] = message
-
redirect_to admin_cache_path
-
end
-
rescue => e
-
error_message = "Redis connection test failed: #{e.message}"
-
if request.xhr?
-
render json: { success: false, message: error_message }
-
else
-
flash[:alert] = error_message
-
redirect_to admin_cache_path
-
end
-
end
-
end
-
-
def flush_cache
-
begin
-
# Flush Rails cache
-
Rails.cache.clear
-
-
# Also flush Redis directly if available
-
redis_url = SiteSetting.get('redis_url', ENV['REDIS_URL'])
-
if defined?(Redis) && redis_url
-
redis = Redis.new(url: redis_url)
-
redis.flushdb
-
redis.quit
-
end
-
-
message = "Cache flushed successfully!"
-
if request.xhr?
-
render json: { success: true, message: message }
-
else
-
flash[:notice] = message
-
redirect_to admin_cache_path
-
end
-
rescue => e
-
error_message = "Failed to flush cache: #{e.message}"
-
if request.xhr?
-
render json: { success: false, message: error_message }
-
else
-
flash[:alert] = error_message
-
redirect_to admin_cache_path
-
end
-
end
-
end
-
-
def stats
-
begin
-
if defined?(Redis) && ENV['REDIS_URL']
-
redis = Redis.new(url: ENV['REDIS_URL'])
-
info = redis.info
-
-
# Get database size
-
db_size = redis.dbsize
-
-
# Calculate hit rate if available
-
hit_rate = 0
-
if info['keyspace_hits'] && info['keyspace_misses']
-
total_requests = info['keyspace_hits'].to_f + info['keyspace_misses'].to_f
-
hit_rate = total_requests > 0 ? (info['keyspace_hits'].to_f / total_requests) : 0
-
end
-
-
redis.quit
-
-
render json: {
-
success: true,
-
stats: {
-
total_keys: db_size,
-
memory_usage: info['used_memory_human'],
-
hit_rate: hit_rate,
-
connected_clients: info['connected_clients'],
-
uptime: info['uptime_in_seconds'],
-
version: info['redis_version']
-
}
-
}
-
else
-
render json: {
-
success: false,
-
message: "Redis not available"
-
}
-
end
-
rescue => e
-
render json: {
-
success: false,
-
message: "Failed to get Redis stats: #{e.message}"
-
}
-
end
-
end
-
-
def enable
-
begin
-
SiteSetting.set('redis_enabled', true, 'general')
-
flash[:notice] = "Cache enabled successfully!"
-
rescue => e
-
flash[:alert] = "Failed to enable cache: #{e.message}"
-
end
-
redirect_to admin_cache_path
-
end
-
-
def disable
-
begin
-
SiteSetting.set('redis_enabled', false, 'general')
-
flash[:notice] = "Cache disabled successfully!"
-
rescue => e
-
flash[:alert] = "Failed to disable cache: #{e.message}"
-
end
-
redirect_to admin_cache_path
-
end
-
-
def clear
-
begin
-
# Clear Rails cache
-
Rails.cache.clear
-
-
# Also clear Redis directly if available
-
if defined?(Redis) && ENV['REDIS_URL']
-
redis = Redis.new(url: ENV['REDIS_URL'])
-
redis.flushdb
-
redis.quit
-
end
-
-
flash[:notice] = "Cache cleared successfully!"
-
rescue => e
-
flash[:alert] = "Failed to clear cache: #{e.message}"
-
end
-
redirect_to admin_cache_path
-
end
-
end
-
class Admin::CategoriesController < Admin::BaseController
-
before_action :set_taxonomy
-
before_action :set_term, only: %i[ show edit update destroy ]
-
-
# GET /admin/categories or /admin/categories.json
-
def index
-
@terms = @taxonomy.terms.includes(:term_relationships).order(:name)
-
-
respond_to do |format|
-
format.html
-
format.json {
-
render json: @terms.map { |term|
-
{
-
id: term.id,
-
name: term.name,
-
slug: term.slug,
-
description: term.description,
-
posts_count: term.term_relationships.where(object_type: 'Post').count,
-
parent_id: term.parent_id,
-
parent_name: term.parent&.name,
-
created_at: term.created_at.strftime('%B %d, %Y')
-
}
-
}
-
}
-
end
-
end
-
-
# GET /admin/categories/1 or /admin/categories/1.json
-
def show
-
@posts = Post.joins(:term_relationships)
-
.where(term_relationships: { term_id: @term.id })
-
.order(created_at: :desc)
-
.page(params[:page])
-
end
-
-
# GET /admin/categories/new
-
def new
-
@term = @taxonomy.terms.new
-
@parent_categories = @taxonomy.terms.where(parent_id: nil).order(:name)
-
end
-
-
# GET /admin/categories/1/edit
-
def edit
-
@parent_categories = @taxonomy.terms.where(parent_id: nil).where.not(id: @term.id).order(:name)
-
end
-
-
# POST /admin/categories or /admin/categories.json
-
def create
-
@term = @taxonomy.terms.new(term_params)
-
-
respond_to do |format|
-
if @term.save
-
format.html { redirect_to admin_category_path(@term), notice: "Category was successfully created." }
-
format.json { render :show, status: :created, location: admin_category_path(@term) }
-
else
-
@parent_categories = @taxonomy.terms.where(parent_id: nil).order(:name)
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @term.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/categories/1 or /admin/categories/1.json
-
def update
-
respond_to do |format|
-
if @term.update(term_params)
-
format.html { redirect_to admin_category_path(@term), notice: "Category was successfully updated.", status: :see_other }
-
format.json { render :show, status: :ok, location: admin_category_path(@term) }
-
else
-
@parent_categories = @taxonomy.terms.where(parent_id: nil).where.not(id: @term.id).order(:name)
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @term.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/categories/1 or /admin/categories/1.json
-
def destroy
-
# Check if category has posts
-
posts_count = @term.term_relationships.where(object_type: 'Post').count
-
-
if posts_count > 0 && @term.slug == 'uncategorized'
-
respond_to do |format|
-
format.html { redirect_to admin_categories_path, alert: "Cannot delete the Uncategorized category.", status: :see_other }
-
format.json { render json: { error: "Cannot delete default category" }, status: :unprocessable_entity }
-
end
-
return
-
end
-
-
# Move posts to Uncategorized if deleting non-default category
-
if posts_count > 0
-
uncategorized = @taxonomy.terms.find_by(slug: 'uncategorized')
-
@term.term_relationships.where(object_type: 'Post').each do |rel|
-
rel.update(term_id: uncategorized.id) if uncategorized
-
end
-
end
-
-
@term.destroy!
-
-
respond_to do |format|
-
format.html { redirect_to admin_categories_path, notice: "Category was successfully deleted.", status: :see_other }
-
format.json { head :no_content }
-
end
-
end
-
-
private
-
# Set the category taxonomy
-
def set_taxonomy
-
@taxonomy = Taxonomy.find_by!(slug: 'category')
-
rescue ActiveRecord::RecordNotFound
-
redirect_to admin_taxonomies_path, alert: "Category taxonomy not found. Please run seeds."
-
end
-
-
# Use callbacks to share common setup or constraints between actions.
-
def set_term
-
@term = @taxonomy.terms.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to admin_categories_path, alert: "Category not found."
-
end
-
-
# Only allow a list of trusted parameters through.
-
def term_params
-
params.require(:term).permit(:name, :slug, :description, :parent_id, :meta)
-
end
-
end
-
class Admin::ChannelOverridesController < Admin::BaseController
-
before_action :set_channel
-
before_action :set_channel_override, only: [:show, :edit, :update, :destroy]
-
-
def index
-
@overrides = @channel.channel_overrides.includes(:resource).order(:resource_type, :path)
-
@overrides_by_type = @overrides.group_by(&:resource_type)
-
end
-
-
def show
-
end
-
-
def new
-
@channel_override = @channel.channel_overrides.build
-
@resource_types = %w[Post Page Medium Setting]
-
end
-
-
def create
-
@channel_override = @channel.channel_overrides.build(channel_override_params)
-
-
if @channel_override.save
-
redirect_to admin_channel_channel_overrides_path(@channel), notice: 'Override was successfully created.'
-
else
-
@resource_types = %w[Post Page Medium Setting]
-
render :new
-
end
-
end
-
-
def edit
-
@resource_types = %w[Post Page Medium Setting]
-
end
-
-
def update
-
if @channel_override.update(channel_override_params)
-
redirect_to admin_channel_channel_overrides_path(@channel), notice: 'Override was successfully updated.'
-
else
-
@resource_types = %w[Post Page Medium Setting]
-
render :edit
-
end
-
end
-
-
def destroy
-
@channel_override.destroy
-
redirect_to admin_channel_channel_overrides_path(@channel), notice: 'Override was successfully deleted.'
-
end
-
-
def copy_from_channel
-
source_channel = Channel.find(params[:source_channel_id])
-
-
source_channel.channel_overrides.each do |override|
-
new_override = override.dup
-
new_override.channel = @channel
-
new_override.save
-
end
-
-
redirect_to admin_channel_channel_overrides_path(@channel), notice: "Overrides copied from #{source_channel.name}."
-
end
-
-
def export
-
overrides_data = @channel.channel_overrides.map do |override|
-
{
-
resource_type: override.resource_type,
-
resource_id: override.resource_id,
-
kind: override.kind,
-
path: override.path,
-
data: override.data,
-
enabled: override.enabled
-
}
-
end
-
-
respond_to do |format|
-
format.json { render json: { channel: @channel.name, overrides: overrides_data } }
-
format.yaml { render plain: overrides_data.to_yaml }
-
end
-
end
-
-
def import
-
if params[:file].present?
-
begin
-
data = case File.extname(params[:file].original_filename)
-
when '.json'
-
JSON.parse(params[:file].read)
-
when '.yml', '.yaml'
-
YAML.load(params[:file].read)
-
else
-
raise "Unsupported file format"
-
end
-
-
overrides_data = data.is_a?(Hash) && data['overrides'] ? data['overrides'] : data
-
-
overrides_data.each do |override_data|
-
@channel.channel_overrides.create!(
-
resource_type: override_data['resource_type'],
-
resource_id: override_data['resource_id'],
-
kind: override_data['kind'],
-
path: override_data['path'],
-
data: override_data['data'],
-
enabled: override_data['enabled']
-
)
-
end
-
-
redirect_to admin_channel_channel_overrides_path(@channel), notice: 'Overrides imported successfully.'
-
rescue => e
-
redirect_to admin_channel_channel_overrides_path(@channel), alert: "Import failed: #{e.message}"
-
end
-
else
-
redirect_to admin_channel_channel_overrides_path(@channel), alert: 'No file provided.'
-
end
-
end
-
-
private
-
-
def set_channel
-
@channel = Channel.find(params[:channel_id])
-
end
-
-
def set_channel_override
-
@channel_override = @channel.channel_overrides.find(params[:id])
-
end
-
-
def channel_override_params
-
params.require(:channel_override).permit(:resource_type, :resource_id, :kind, :path, :enabled, data: {})
-
end
-
end
-
class Admin::ChannelsController < Admin::BaseController
-
before_action :set_channel, only: [:show, :edit, :update, :destroy]
-
-
def index
-
@channels = Channel.all.order(:name)
-
end
-
-
def show
-
@overrides = @channel.channel_overrides.includes(:resource).order(:resource_type, :path)
-
@overrides_by_type = @overrides.group_by(&:resource_type)
-
end
-
-
def new
-
@channel = Channel.new
-
end
-
-
def create
-
@channel = Channel.new(channel_params)
-
-
if @channel.save
-
redirect_to admin_channel_path(@channel), notice: 'Channel was successfully created.'
-
else
-
render :new
-
end
-
end
-
-
def edit
-
end
-
-
def update
-
if @channel.update(channel_params)
-
redirect_to admin_channel_path(@channel), notice: 'Channel was successfully updated.'
-
else
-
render :edit
-
end
-
end
-
-
def destroy
-
@channel.destroy
-
redirect_to admin_channels_path, notice: 'Channel was successfully deleted.'
-
end
-
-
private
-
-
def set_channel
-
@channel = Channel.find(params[:id])
-
end
-
-
def channel_params
-
params.require(:channel).permit(:name, :slug, :domain, :locale, metadata: {}, settings: {})
-
end
-
end
-
class Admin::CommentsController < Admin::BaseController
-
before_action :set_comment, only: %i[ show edit update destroy ]
-
-
# GET /admin/comments or /admin/comments.json
-
def index
-
@comments = Comment.kept.includes(:commentable, :user).order(created_at: :desc)
-
-
# Show trashed if explicitly requested
-
if params[:show_trash] == 'true'
-
@comments = Comment.trashed.includes(:commentable, :user).order(deleted_at: :desc)
-
end
-
-
respond_to do |format|
-
format.html do
-
@comments_data = comments_json
-
@stats = {
-
total: Comment.kept.count,
-
approved: Comment.kept.where(status: 'approved').count,
-
pending: Comment.kept.where(status: 'pending').count,
-
spam: Comment.kept.where(status: 'spam').count
-
}
-
@bulk_actions = [
-
{ value: 'approve', label: 'Approve' },
-
{ value: 'unapprove', label: 'Unapprove' },
-
{ value: 'spam', label: 'Mark as Spam' },
-
{ value: 'trash', label: 'Move to Trash' },
-
{ value: 'untrash', label: 'Restore' }
-
]
-
@status_options = [
-
{ value: 'approved', label: 'Approved' },
-
{ value: 'pending', label: 'Pending' },
-
{ value: 'spam', label: 'Spam' },
-
{ value: 'trash', label: 'Trash' }
-
]
-
@columns = [
-
{
-
title: "",
-
formatter: "rowSelection",
-
titleFormatter: "rowSelection",
-
width: 40,
-
headerSort: false
-
},
-
{
-
title: "Author",
-
field: "author_name",
-
width: 150,
-
formatter: "html"
-
},
-
{
-
title: "Comment",
-
field: "content",
-
width: 250,
-
formatter: "html"
-
},
-
{
-
title: "Type",
-
field: "type",
-
width: 80,
-
formatter: "html"
-
},
-
{
-
title: "In Response To",
-
field: "commentable_title",
-
width: 150,
-
formatter: "html"
-
},
-
{
-
title: "Status",
-
field: "status",
-
width: 100,
-
formatter: "html"
-
},
-
{
-
title: "IP",
-
field: "author_ip",
-
width: 120
-
},
-
{
-
title: "Browser",
-
field: "browser_info",
-
width: 100
-
},
-
{
-
title: "Date",
-
field: "created_at",
-
width: 150,
-
formatter: "datetime",
-
formatterParams: {
-
inputFormat: "YYYY-MM-DDTHH:mm:ss.SSSZ",
-
outputFormat: "DD/MM/YYYY HH:mm"
-
}
-
},
-
{
-
title: "Actions",
-
field: "actions",
-
width: 120,
-
headerSort: false,
-
formatter: "html"
-
}
-
]
-
end
-
format.json { render json: comments_json }
-
end
-
end
-
-
# GET /admin/comments/1 or /admin/comments/1.json
-
def show
-
end
-
-
-
# GET /admin/comments/1/edit
-
def edit
-
end
-
-
# POST /admin/comments or /admin/comments.json
-
def create
-
@comment = Comment.new(comment_params)
-
-
respond_to do |format|
-
if @comment.save
-
format.html { redirect_to admin_comments_path, notice: "Comment was successfully created." }
-
format.json { render :show, status: :created, location: @comment }
-
else
-
format.html { redirect_to admin_comments_path, alert: "Failed to create comment." }
-
format.json { render json: @comment.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/comments/1 or /admin/comments/1.json
-
def update
-
respond_to do |format|
-
if @comment.update(comment_params)
-
format.html { redirect_to [:admin, @comment], notice: "Comment was successfully updated.", status: :see_other }
-
format.json { render :show, status: :ok, location: @comment }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @comment.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/comments/1 or /admin/comments/1.json
-
def destroy
-
if @comment.trashed?
-
@comment.destroy_permanently! # Permanent delete
-
notice = "Comment was permanently deleted."
-
else
-
@comment.trash!(current_user) # Soft delete
-
notice = "Comment was moved to trash."
-
end
-
-
respond_to do |format|
-
format.html { redirect_to admin_comments_path, notice: notice, status: :see_other }
-
format.json { head :no_content }
-
end
-
end
-
-
# POST /admin/comments/bulk_action
-
def bulk_action
-
action_type = params[:action_type]
-
comment_ids = params[:ids] || []
-
-
comments = Comment.where(id: comment_ids)
-
-
case action_type
-
when 'approve'
-
comments.find_each(&:approve!)
-
message = "#{comments.count} comments approved"
-
when 'unapprove'
-
comments.find_each(&:unapprove!)
-
message = "#{comments.count} comments unapproved"
-
when 'spam'
-
comments.find_each { |comment| comment.update!(status: :spam) }
-
message = "#{comments.count} comments marked as spam"
-
when 'trash'
-
comments.find_each { |comment| comment.trash!(current_user) }
-
message = "#{comments.count} comments moved to trash"
-
when 'untrash'
-
comments.find_each(&:untrash!)
-
message = "#{comments.count} comments restored from trash"
-
else
-
message = "Invalid action"
-
end
-
-
respond_to do |format|
-
format.json { render json: { success: true, message: message } }
-
end
-
end
-
-
private
-
# Use callbacks to share common setup or constraints between actions.
-
def set_comment
-
@comment = Comment.find(params[:id])
-
end
-
-
# Only allow a list of trusted parameters through.
-
def comment_params
-
params.require(:comment).permit(
-
:content, :author_name, :author_email, :author_url, :author_ip, :author_agent,
-
:status, :comment_type, :comment_approved, :comment_parent_id, :user_id,
-
:commentable_type, :commentable_id, :parent_id
-
)
-
end
-
-
def comments_json
-
@comments.map do |comment|
-
{
-
id: comment.id,
-
author_name: format_author_name(comment),
-
author_email: comment.author_email,
-
content: format_content(comment.content),
-
type: format_comment_type(comment.comment_type),
-
status: format_status_badge(comment.status),
-
status_raw: comment.status,
-
commentable_type: comment.commentable_type,
-
commentable_title: comment.commentable&.title || 'Unknown',
-
author_ip: comment.author_ip,
-
browser_info: comment.browser_info,
-
created_at: comment.created_at.iso8601,
-
edit_url: edit_admin_comment_path(comment),
-
show_url: admin_comment_path(comment),
-
delete_url: nil
-
}
-
end
-
end
-
-
def format_author_name(comment)
-
if comment.user.present?
-
"<div class='flex flex-col'>
-
<span class='font-medium text-gray-900'>#{comment.user.name}</span>
-
<span class='text-xs text-gray-500'>(#{comment.author_name})</span>
-
</div>"
-
else
-
"<span class='font-medium text-gray-900'>#{comment.author_name}</span>"
-
end
-
end
-
-
def format_content(content)
-
truncated = content.length > 100 ? content[0..100] + '...' : content
-
"<div class='text-sm text-gray-700'>#{truncated}</div>"
-
end
-
-
def format_comment_type(type)
-
type_map = {
-
'comment' => { class: 'bg-blue-100 text-blue-800', label: 'Comment' },
-
'pingback' => { class: 'bg-purple-100 text-purple-800', label: 'Pingback' },
-
'trackback' => { class: 'bg-orange-100 text-orange-800', label: 'Trackback' }
-
}
-
-
type_info = type_map[type] || { class: 'bg-gray-100 text-gray-800', label: type&.capitalize || 'Unknown' }
-
"<span class='px-2 py-1 text-xs font-medium rounded-full #{type_info[:class]}'>#{type_info[:label]}</span>"
-
end
-
-
def format_status_badge(status)
-
status_map = {
-
'approved' => { class: 'bg-green-100 text-green-800', label: 'Approved' },
-
'pending' => { class: 'bg-yellow-100 text-yellow-800', label: 'Pending' },
-
'spam' => { class: 'bg-red-100 text-red-800', label: 'Spam' },
-
'trash' => { class: 'bg-gray-100 text-gray-800', label: 'Trash' }
-
}
-
-
status_info = status_map[status] || { class: 'bg-gray-100 text-gray-800', label: status&.capitalize || 'Unknown' }
-
"<span class='px-2 py-1 text-xs font-medium rounded-full #{status_info[:class]}'>#{status_info[:label]}</span>"
-
end
-
end
-
class Admin::ConsentController < Admin::BaseController
-
before_action :set_consent_configuration, only: [:show, :edit, :update, :destroy]
-
before_action :set_pixel, only: [:pixel_consent_settings]
-
-
# GET /admin/consent
-
def index
-
@consent_configs = ConsentConfiguration.includes(:tenant).ordered
-
@stats = {
-
total_configs: ConsentConfiguration.count,
-
active_configs: ConsentConfiguration.active.count,
-
total_consents: UserConsent.count,
-
granted_consents: UserConsent.granted.count,
-
withdrawn_consents: UserConsent.withdrawn.count
-
}
-
end
-
-
# GET /admin/consent/new
-
def new
-
@consent_config = ConsentConfiguration.new
-
end
-
-
# GET /admin/consent/:id
-
def show
-
@recent_consents = UserConsent.recent.limit(50)
-
@consent_stats = {
-
by_type: UserConsent.group(:consent_type).count,
-
by_status: UserConsent.group(:granted).count,
-
recent_granted: UserConsent.granted.where('granted_at > ?', 7.days.ago).count,
-
recent_withdrawn: UserConsent.withdrawn.where('withdrawn_at > ?', 7.days.ago).count
-
}
-
end
-
-
# GET /admin/consent/:id/edit
-
def edit
-
end
-
-
# POST /admin/consent
-
def create
-
@consent_config = ConsentConfiguration.new(consent_configuration_params)
-
-
if @consent_config.save
-
redirect_to admin_consent_path(@consent_config), notice: 'Consent configuration created successfully.'
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /admin/consent/:id
-
def update
-
if @consent_config.update(consent_configuration_params)
-
redirect_to admin_consent_path(@consent_config), notice: 'Consent configuration updated successfully.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/consent/:id
-
def destroy
-
@consent_config.destroy
-
redirect_to admin_consent_index_path, notice: 'Consent configuration deleted successfully.'
-
end
-
-
# GET /admin/consent/pixels
-
def pixels
-
@pixels = Pixel.active.includes(:tenant).ordered
-
@consent_config = ConsentConfiguration.active.first
-
-
# Group pixels by consent category
-
@pixels_by_category = {}
-
if @consent_config
-
@consent_config.consent_categories_with_defaults.each do |category, settings|
-
@pixels_by_category[category] = @pixels.select do |pixel|
-
@consent_config.get_consent_categories_for_pixel(pixel.pixel_type).include?(category)
-
end
-
end
-
end
-
end
-
-
# GET /admin/consent/pixels/:id/consent_settings
-
def pixel_consent_settings
-
@consent_config = ConsentConfiguration.active.first
-
@consent_categories = @consent_config&.consent_categories_with_defaults || {}
-
@current_mapping = @consent_config&.pixel_consent_mapping_with_defaults || {}
-
end
-
-
# PATCH /admin/consent/pixels/:id/update_consent_mapping
-
def update_pixel_consent_mapping
-
pixel_id = params[:id]
-
consent_categories = params[:consent_categories] || []
-
-
begin
-
consent_config = ConsentConfiguration.active.first
-
if consent_config
-
# Update pixel consent mapping
-
current_mapping = consent_config.pixel_consent_mapping_with_defaults
-
-
# Remove pixel from all categories first
-
current_mapping.each do |category, pixels|
-
current_mapping[category] = pixels - [Pixel.find(pixel_id).pixel_type]
-
end
-
-
# Add pixel to selected categories
-
consent_categories.each do |category|
-
current_mapping[category] ||= []
-
current_mapping[category] << Pixel.find(pixel_id).pixel_type
-
current_mapping[category].uniq!
-
end
-
-
consent_config.update!(pixel_consent_mapping: current_mapping)
-
-
render json: {
-
success: true,
-
message: 'Pixel consent mapping updated successfully'
-
}
-
else
-
render json: {
-
success: false,
-
error: 'No active consent configuration found'
-
}, status: :not_found
-
end
-
-
rescue => e
-
Rails.logger.error "Pixel consent mapping update error: #{e.message}"
-
render json: {
-
success: false,
-
error: 'Failed to update pixel consent mapping'
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# GET /admin/consent/users
-
def users
-
@users = User.includes(:user_consents).page(params[:page]).per(50)
-
-
# Filter by consent status
-
if params[:consent_status].present?
-
case params[:consent_status]
-
when 'has_consent'
-
@users = @users.joins(:user_consents).where(user_consents: { granted: true })
-
when 'no_consent'
-
@users = @users.left_joins(:user_consents).where(user_consents: { id: nil })
-
when 'withdrawn_consent'
-
@users = @users.joins(:user_consents).where.not(user_consents: { withdrawn_at: nil })
-
end
-
end
-
-
# Filter by consent type
-
if params[:consent_type].present?
-
@users = @users.joins(:user_consents).where(user_consents: { consent_type: params[:consent_type] })
-
end
-
end
-
-
# GET /admin/consent/users/:id
-
def user_consents
-
@user = User.find(params[:id])
-
@user_consents = @user.user_consents.recent
-
@consent_config = ConsentConfiguration.active.first
-
end
-
-
# POST /admin/consent/users/:id/export_data
-
def export_user_data
-
@user = User.find(params[:id])
-
-
begin
-
# Create data export request
-
export_request = PersonalDataExportRequest.create!(
-
user: @user,
-
status: 'pending',
-
requested_at: Time.current,
-
expires_at: 30.days.from_now
-
)
-
-
# Queue background job
-
PersonalDataExportWorker.perform_async(export_request.id)
-
-
redirect_to admin_consent_user_consents_path(@user),
-
notice: 'Data export request created successfully. You will be notified when ready.'
-
rescue => e
-
Rails.logger.error "Data export error: #{e.message}"
-
redirect_to admin_consent_user_consents_path(@user),
-
alert: 'Failed to create data export request.'
-
end
-
end
-
-
# DELETE /admin/consent/users/:id/consent/:consent_type
-
def withdraw_user_consent
-
@user = User.find(params[:id])
-
consent_type = params[:consent_type]
-
-
begin
-
user_consent = @user.user_consents.find_by(consent_type: consent_type)
-
-
if user_consent
-
user_consent.withdraw!
-
redirect_to admin_consent_user_consents_path(@user),
-
notice: "#{consent_type.humanize} consent withdrawn successfully."
-
else
-
redirect_to admin_consent_user_consents_path(@user),
-
alert: 'Consent not found.'
-
end
-
rescue => e
-
Rails.logger.error "Consent withdrawal error: #{e.message}"
-
redirect_to admin_consent_user_consents_path(@user),
-
alert: 'Failed to withdraw consent.'
-
end
-
end
-
-
# GET /admin/consent/analytics
-
def analytics
-
@time_range = params[:time_range] || '30_days'
-
-
# Calculate time range
-
case @time_range
-
when '7_days'
-
start_date = 7.days.ago
-
when '30_days'
-
start_date = 30.days.ago
-
when '90_days'
-
start_date = 90.days.ago
-
when '1_year'
-
start_date = 1.year.ago
-
else
-
start_date = 30.days.ago
-
end
-
-
@analytics = {
-
consent_granted: UserConsent.granted.where('granted_at > ?', start_date).count,
-
consent_withdrawn: UserConsent.withdrawn.where('withdrawn_at > ?', start_date).count,
-
consent_by_type: UserConsent.where('granted_at > ? OR withdrawn_at > ?', start_date, start_date)
-
.group(:consent_type)
-
.group(:granted)
-
.count,
-
daily_consents: UserConsent.where('granted_at > ?', start_date)
-
.group("DATE(granted_at)")
-
.count,
-
consent_rate: calculate_consent_rate(start_date)
-
}
-
end
-
-
# GET /admin/consent/compliance
-
def compliance
-
@consent_config = ConsentConfiguration.active.first
-
@compliance_report = generate_compliance_report
-
end
-
-
# GET /admin/consent/settings
-
def settings
-
@consent_config = ConsentConfiguration.active.first || ConsentConfiguration.new
-
end
-
-
# PATCH /admin/consent/settings
-
def update_settings
-
@consent_config = ConsentConfiguration.active.first || ConsentConfiguration.new
-
-
if @consent_config.update(consent_configuration_params)
-
redirect_to admin_consent_settings_path, notice: 'Consent settings updated successfully.'
-
else
-
render :settings, status: :unprocessable_entity
-
end
-
end
-
-
# POST /admin/consent/test_banner
-
def test_banner
-
@consent_config = ConsentConfiguration.active.first
-
-
if @consent_config
-
render json: {
-
banner_html: @consent_config.generate_banner_html,
-
banner_css: @consent_config.generate_banner_css
-
}
-
else
-
render json: {
-
error: 'No active consent configuration found'
-
}, status: :not_found
-
end
-
end
-
-
private
-
-
def set_consent_configuration
-
@consent_config = ConsentConfiguration.find(params[:id])
-
end
-
-
def set_pixel
-
@pixel = Pixel.find(params[:id])
-
end
-
-
def consent_configuration_params
-
params.require(:consent_configuration).permit(
-
:name,
-
:banner_type,
-
:consent_mode,
-
:active,
-
consent_categories: {},
-
pixel_consent_mapping: {},
-
banner_settings: {},
-
geolocation_settings: {}
-
)
-
end
-
-
def calculate_consent_rate(start_date)
-
total_users = User.where('created_at > ?', start_date).count
-
users_with_consent = User.joins(:user_consents)
-
.where('user_consents.granted_at > ?', start_date)
-
.distinct.count
-
-
return 0 if total_users == 0
-
-
(users_with_consent.to_f / total_users * 100).round(2)
-
end
-
-
def generate_compliance_report
-
{
-
gdpr_compliance: {
-
data_subject_rights: check_data_subject_rights,
-
consent_management: check_consent_management,
-
data_processing_records: check_data_processing_records,
-
privacy_by_design: check_privacy_by_design
-
},
-
ccpa_compliance: {
-
consumer_rights: check_consumer_rights,
-
opt_out_mechanism: check_opt_out_mechanism,
-
data_disclosure: check_data_disclosure
-
},
-
overall_score: calculate_overall_compliance_score
-
}
-
end
-
-
def check_data_subject_rights
-
# Check if data subject rights are properly implemented
-
{
-
access_right: UserConsent.exists?,
-
rectification_right: true, # Implemented in user management
-
erasure_right: PersonalDataErasureRequest.exists?,
-
portability_right: PersonalDataExportRequest.exists?,
-
objection_right: UserConsent.withdrawn.exists?,
-
score: 85
-
}
-
end
-
-
def check_consent_management
-
# Check consent management implementation
-
{
-
explicit_consent: UserConsent.granted.exists?,
-
consent_withdrawal: UserConsent.withdrawn.exists?,
-
consent_records: UserConsent.count > 0,
-
consent_audit_trail: true, # Implemented with timestamps
-
score: 90
-
}
-
end
-
-
def check_data_processing_records
-
# Check data processing records
-
{
-
processing_activities_documented: ConsentConfiguration.exists?,
-
legal_basis_identified: true,
-
data_categories_documented: true,
-
retention_periods_set: true,
-
score: 80
-
}
-
end
-
-
def check_privacy_by_design
-
# Check privacy by design implementation
-
{
-
consent_banner_implemented: ConsentConfiguration.active.exists?,
-
data_minimization: true,
-
purpose_limitation: true,
-
storage_limitation: true,
-
score: 75
-
}
-
end
-
-
def check_consumer_rights
-
# Check CCPA consumer rights
-
{
-
right_to_know: PersonalDataExportRequest.exists?,
-
right_to_delete: PersonalDataErasureRequest.exists?,
-
right_to_opt_out: UserConsent.withdrawn.exists?,
-
non_discrimination: true,
-
score: 85
-
}
-
end
-
-
def check_opt_out_mechanism
-
# Check opt-out mechanism
-
{
-
opt_out_link_available: true,
-
opt_out_process_clear: true,
-
opt_out_confirmation: true,
-
score: 90
-
}
-
end
-
-
def check_data_disclosure
-
# Check data disclosure practices
-
{
-
privacy_policy_available: true,
-
data_categories_disclosed: true,
-
third_party_sharing_disclosed: true,
-
score: 80
-
}
-
end
-
-
def calculate_overall_compliance_score
-
scores = [
-
check_data_subject_rights[:score],
-
check_consent_management[:score],
-
check_data_processing_records[:score],
-
check_privacy_by_design[:score],
-
check_consumer_rights[:score],
-
check_opt_out_mechanism[:score],
-
check_data_disclosure[:score]
-
]
-
-
(scores.sum.to_f / scores.length).round(2)
-
end
-
end
-
class Admin::ContentAnalyticsController < Admin::BaseController
-
before_action :ensure_admin
-
-
# GET /admin/analytics/posts/:id
-
def post
-
@post = Post.find(params[:id])
-
@period = params[:period] || 'month'
-
@analytics = ContentAnalyticsService.post_analytics(@post.id, period: @period.to_sym)
-
-
# Chart data for views over time
-
@views_chart_data = @analytics[:views_by_day].map do |date, count|
-
{ date: date, views: count }
-
end
-
-
# Chart data for views by hour
-
@hourly_chart_data = @analytics[:views_by_hour].map do |hour, count|
-
{ hour: hour, views: count }
-
end
-
-
# Chart data for reader demographics
-
@country_chart_data = @analytics[:readers_by_country].first(10).map do |country, count|
-
{ country: country, readers: count }
-
end
-
-
@device_chart_data = @analytics[:readers_by_device].map do |device, count|
-
{ device: device, readers: count }
-
end
-
end
-
-
# GET /admin/analytics/pages/:id
-
def page
-
@page = Page.find(params[:id])
-
@period = params[:period] || 'month'
-
@analytics = ContentAnalyticsService.page_analytics(@page.id, period: @period.to_sym)
-
-
# Chart data for views over time
-
@views_chart_data = @analytics[:views_by_day].map do |date, count|
-
{ date: date, views: count }
-
end
-
-
# Chart data for views by hour
-
@hourly_chart_data = @analytics[:views_by_hour].map do |hour, count|
-
{ hour: hour, views: count }
-
end
-
-
# Chart data for visitor demographics
-
@country_chart_data = @analytics[:visitors_by_country].first(10).map do |country, count|
-
{ country: country, visitors: count }
-
end
-
-
@device_chart_data = @analytics[:visitors_by_device].map do |device, count|
-
{ device: device, visitors: count }
-
end
-
end
-
-
# GET /admin/analytics/content/performance
-
def performance
-
@period = params[:period] || 'month'
-
@limit = params[:limit]&.to_i || 10
-
@performance_data = ContentAnalyticsService.top_performing_content(
-
period: @period.to_sym,
-
limit: @limit
-
)
-
end
-
-
# GET /admin/analytics/content/engagement
-
def engagement
-
@period = params[:period] || 'month'
-
@engagement_data = ContentAnalyticsService.reader_engagement_insights(period: @period.to_sym)
-
-
# Chart data for engagement levels
-
@engagement_chart_data = [
-
{ level: 'Low', count: @engagement_data[:low_engagement] },
-
{ level: 'Medium', count: @engagement_data[:medium_engagement] },
-
{ level: 'High', count: @engagement_data[:high_engagement] }
-
]
-
-
# Chart data for reader segments
-
@reader_segments_chart_data = [
-
{ segment: 'Quick Readers', count: @engagement_data[:quick_readers] },
-
{ segment: 'Engaged Readers', count: @engagement_data[:engaged_readers] },
-
{ segment: 'Deep Readers', count: @engagement_data[:deep_readers] }
-
]
-
-
# Chart data for scroll behavior
-
@scroll_chart_data = [
-
{ milestone: '25%', count: @engagement_data[:readers_who_scrolled_25] },
-
{ milestone: '50%', count: @engagement_data[:readers_who_scrolled_50] },
-
{ milestone: '75%', count: @engagement_data[:readers_who_scrolled_75] },
-
{ milestone: '100%', count: @engagement_data[:readers_who_scrolled_100] }
-
]
-
end
-
-
# GET /admin/analytics/content/export
-
def export
-
@period = params[:period] || 'month'
-
@content_type = params[:content_type] || 'all' # all, posts, pages
-
-
case @content_type
-
when 'posts'
-
@content = Post.published.includes(:pageviews)
-
when 'pages'
-
@content = Page.published.includes(:pageviews)
-
else
-
@content = Post.published.includes(:pageviews) + Page.published.includes(:pageviews)
-
end
-
-
respond_to do |format|
-
format.csv do
-
send_data generate_csv(@content, @period),
-
filename: "content-analytics-#{@content_type}-#{@period}-#{Date.today}.csv",
-
type: 'text/csv',
-
disposition: 'attachment'
-
end
-
end
-
end
-
-
private
-
-
def generate_csv(content, period)
-
require 'csv'
-
-
CSV.generate(headers: true) do |csv|
-
csv << [
-
'Content Type', 'Title', 'Slug', 'Published Date', 'Total Views',
-
'Unique Readers', 'Avg Reading Time', 'Avg Completion Rate',
-
'Engagement Score', 'URL'
-
]
-
-
content.each do |item|
-
range = period_range(period)
-
pageviews = item.pageviews.where(visited_at: range).non_bot.consented_only
-
-
csv << [
-
item.class.name,
-
item.title,
-
item.slug,
-
item.published_at&.strftime('%Y-%m-%d'),
-
pageviews.count,
-
pageviews.distinct.count(:session_id),
-
pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
-
pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
-
ContentAnalyticsService.calculate_engagement_score(pageviews),
-
Rails.application.routes.url_helpers.url_for(item)
-
]
-
end
-
end
-
end
-
-
def period_range(period)
-
case period.to_sym
-
when :today
-
Time.current.beginning_of_day..Time.current.end_of_day
-
when :week
-
1.week.ago..Time.current
-
when :month
-
1.month.ago..Time.current
-
when :year
-
1.year.ago..Time.current
-
else
-
1.month.ago..Time.current
-
end
-
end
-
end
-
class Admin::ContentTypesController < Admin::BaseController
-
before_action :set_content_type, only: %i[ show edit update destroy ]
-
before_action :ensure_admin
-
-
# GET /admin/content_types or /admin/content_types.json
-
def index
-
@content_types = ContentType.all.ordered
-
-
respond_to do |format|
-
format.html
-
format.json { render json: content_types_json }
-
end
-
end
-
-
# GET /admin/content_types/1 or /admin/content_types/1.json
-
def show
-
end
-
-
# GET /admin/content_types/new
-
def new
-
@content_type = ContentType.new
-
end
-
-
# GET /admin/content_types/1/edit
-
def edit
-
end
-
-
# POST /admin/content_types or /admin/content_types.json
-
def create
-
@content_type = ContentType.new(content_type_params)
-
-
respond_to do |format|
-
if @content_type.save
-
format.html { redirect_to admin_content_types_path, notice: "Content type was successfully created." }
-
format.json { render :show, status: :created, location: [:admin, @content_type] }
-
else
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @content_type.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/content_types/1 or /admin/content_types/1.json
-
def update
-
respond_to do |format|
-
if @content_type.update(content_type_params)
-
format.html { redirect_to admin_content_types_path, notice: "Content type was successfully updated." }
-
format.json { render :show, status: :ok, location: [:admin, @content_type] }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @content_type.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/content_types/1 or /admin/content_types/1.json
-
def destroy
-
# Don't allow deletion of default 'post' type
-
if @content_type.ident == 'post'
-
respond_to do |format|
-
format.html { redirect_to admin_content_types_path, alert: "Cannot delete the default 'post' content type." }
-
format.json { render json: { error: "Cannot delete default content type" }, status: :unprocessable_entity }
-
end
-
return
-
end
-
-
@content_type.destroy!
-
-
respond_to do |format|
-
format.html { redirect_to admin_content_types_path, notice: "Content type was successfully deleted." }
-
format.json { head :no_content }
-
end
-
end
-
-
private
-
# Use callbacks to share common setup or constraints between actions.
-
def set_content_type
-
@content_type = ContentType.find(params[:id])
-
end
-
-
# Only allow a list of trusted parameters through.
-
def content_type_params
-
params.require(:content_type).permit(
-
:ident, :label, :singular, :plural, :description, :icon,
-
:public, :hierarchical, :has_archive, :menu_position,
-
:rest_base, :active,
-
supports: [], capabilities: {}
-
)
-
end
-
-
def content_types_json
-
@content_types.map do |ct|
-
{
-
id: ct.id,
-
ident: ct.ident,
-
label: ct.label,
-
singular: ct.singular,
-
plural: ct.plural,
-
icon: ct.icon,
-
public: ct.public,
-
hierarchical: ct.hierarchical,
-
has_archive: ct.has_archive,
-
posts_count: ct.posts.count,
-
active: ct.active,
-
created_at: ct.created_at.strftime("%Y-%m-%d %H:%M")
-
}
-
end
-
end
-
-
def ensure_admin
-
unless current_user&.administrator?
-
redirect_to admin_root_path, alert: 'You do not have permission to manage content types.'
-
end
-
end
-
end
-
-
class Admin::DashboardController < Admin::BaseController
-
def index
-
# Content metrics only
-
@posts_count = Post.count
-
@published_posts_count = Post.published.count
-
@pages_count = Page.count
-
@comments_count = Comment.count
-
@pending_comments_count = Comment.pending.count
-
@recent_posts = Post.order(created_at: :desc).limit(5)
-
@recent_comments = Comment.order(created_at: :desc).limit(5)
-
end
-
end
-
class Admin::EmailLogsController < Admin::BaseController
-
include Pagy::Backend
-
-
def index
-
@pagy, @email_logs = pagy(EmailLog.recent, items: 50)
-
@stats = EmailLog.stats
-
end
-
-
def show
-
@email_log = EmailLog.find(params[:id])
-
end
-
-
def destroy
-
@email_log = EmailLog.find(params[:id])
-
@email_log.destroy
-
redirect_to admin_email_logs_path, notice: 'Email log deleted successfully.'
-
end
-
-
def destroy_all
-
EmailLog.delete_all
-
redirect_to admin_email_logs_path, notice: 'All email logs cleared successfully.'
-
end
-
-
def stats
-
render json: EmailLog.stats
-
end
-
end
-
-
class Admin::FieldGroupsController < Admin::BaseController
-
before_action :set_field_group, only: [:show, :edit, :update, :destroy, :toggle, :duplicate]
-
-
# GET /admin/field_groups
-
def index
-
@field_groups = FieldGroup.ordered
-
.includes(:custom_fields)
-
-
@field_groups = @field_groups.where(active: true) if params[:filter] == 'active'
-
@field_groups = @field_groups.where(active: false) if params[:filter] == 'inactive'
-
-
respond_to do |format|
-
format.html
-
format.json {
-
render json: @field_groups.map { |fg| field_group_json(fg) }
-
}
-
end
-
end
-
-
# GET /admin/field_groups/:id
-
def show
-
@fields = @field_group.custom_fields.ordered
-
end
-
-
# GET /admin/field_groups/new
-
def new
-
@field_group = FieldGroup.new
-
@field_group.custom_fields.build # Start with one field
-
end
-
-
# GET /admin/field_groups/:id/edit
-
def edit
-
@field_group.custom_fields.build if @field_group.custom_fields.empty?
-
end
-
-
# POST /admin/field_groups
-
def create
-
@field_group = FieldGroup.new(field_group_params)
-
-
if @field_group.save
-
redirect_to edit_admin_field_group_path(@field_group),
-
notice: 'Field group created successfully.'
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /admin/field_groups/:id
-
def update
-
if @field_group.update(field_group_params)
-
redirect_to edit_admin_field_group_path(@field_group),
-
notice: 'Field group updated successfully.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/field_groups/:id
-
def destroy
-
@field_group.destroy
-
redirect_to admin_field_groups_path, notice: 'Field group deleted successfully.'
-
end
-
-
# PATCH /admin/field_groups/:id/toggle
-
def toggle
-
@field_group.update(active: !@field_group.active)
-
-
respond_to do |format|
-
format.html { redirect_to admin_field_groups_path }
-
format.json { render json: { active: @field_group.active } }
-
end
-
end
-
-
# POST /admin/field_groups/:id/duplicate
-
def duplicate
-
new_field_group = @field_group.dup
-
new_field_group.name = "#{@field_group.name} (Copy)"
-
new_field_group.slug = "#{@field_group.slug}_copy_#{Time.now.to_i}"
-
-
if new_field_group.save
-
# Duplicate fields
-
@field_group.custom_fields.each do |field|
-
new_field = field.dup
-
new_field.field_group = new_field_group
-
new_field.save
-
end
-
-
redirect_to edit_admin_field_group_path(new_field_group),
-
notice: 'Field group duplicated successfully.'
-
else
-
redirect_to admin_field_groups_path, alert: 'Failed to duplicate field group.'
-
end
-
end
-
-
# POST /admin/field_groups/reorder
-
def reorder
-
params[:order].each_with_index do |id, index|
-
FieldGroup.find(id).update(position: index)
-
end
-
-
head :ok
-
end
-
-
private
-
-
def set_field_group
-
@field_group = FieldGroup.find(params[:id])
-
end
-
-
def field_group_params
-
params.require(:field_group).permit(
-
:name,
-
:slug,
-
:description,
-
:position,
-
:active,
-
location_rules: {},
-
custom_fields_attributes: [
-
:id,
-
:name,
-
:label,
-
:field_type,
-
:instructions,
-
:required,
-
:default_value,
-
:position,
-
:_destroy,
-
choices: {},
-
conditional_logic: {},
-
settings: {}
-
]
-
)
-
end
-
-
def field_group_json(field_group)
-
{
-
id: field_group.id,
-
name: field_group.name,
-
slug: field_group.slug,
-
description: field_group.description,
-
active: field_group.active,
-
fields_count: field_group.custom_fields.count,
-
location_rules: field_group.location_rules
-
}
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::FontsController < Admin::BaseController
-
before_action :set_font, only: [:show, :edit, :update, :destroy, :toggle, :preview]
-
-
# GET /admin/fonts
-
def index
-
@fonts = CustomFont.ordered.all
-
-
respond_to do |format|
-
format.html
-
format.json {
-
render json: @fonts.map { |f| font_json(f) }
-
}
-
end
-
end
-
-
# GET /admin/fonts/:id
-
def show
-
end
-
-
# GET /admin/fonts/new
-
def new
-
@font = CustomFont.new
-
@font.weights = ['400']
-
@font.styles = ['normal']
-
end
-
-
# GET /admin/fonts/:id/edit
-
def edit
-
end
-
-
# POST /admin/fonts
-
def create
-
@font = CustomFont.new(font_params)
-
-
if @font.save
-
redirect_to admin_fonts_path, notice: 'Font added successfully.'
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /admin/fonts/:id
-
def update
-
if @font.update(font_params)
-
redirect_to admin_fonts_path, notice: 'Font updated successfully.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/fonts/:id
-
def destroy
-
@font.destroy
-
redirect_to admin_fonts_path, notice: 'Font deleted successfully.'
-
end
-
-
# PATCH /admin/fonts/:id/toggle
-
def toggle
-
@font.update(active: !@font.active)
-
-
respond_to do |format|
-
format.html { redirect_to admin_fonts_path }
-
format.json { render json: { active: @font.active } }
-
end
-
end
-
-
# GET /admin/fonts/:id/preview
-
def preview
-
render layout: false
-
end
-
-
# GET /admin/fonts/google
-
def google
-
# Popular Google Fonts list
-
@popular_fonts = [
-
{ name: 'Inter', category: 'sans-serif', popularity: 1 },
-
{ name: 'Roboto', category: 'sans-serif', popularity: 2 },
-
{ name: 'Open Sans', category: 'sans-serif', popularity: 3 },
-
{ name: 'Lato', category: 'sans-serif', popularity: 4 },
-
{ name: 'Montserrat', category: 'sans-serif', popularity: 5 },
-
{ name: 'Poppins', category: 'sans-serif', popularity: 6 },
-
{ name: 'Raleway', category: 'sans-serif', popularity: 7 },
-
{ name: 'Playfair Display', category: 'serif', popularity: 8 },
-
{ name: 'Merriweather', category: 'serif', popularity: 9 },
-
{ name: 'Roboto Mono', category: 'monospace', popularity: 10 }
-
]
-
-
render layout: false
-
end
-
-
# POST /admin/fonts/add_google
-
def add_google
-
font_family = params[:family]
-
-
font = CustomFont.create!(
-
name: font_family,
-
family: font_family,
-
source: 'google',
-
weights: params[:weights] || ['400'],
-
styles: params[:styles] || ['normal'],
-
fallback: params[:fallback] || 'sans-serif',
-
active: true
-
)
-
-
redirect_to admin_fonts_path, notice: "Added #{font_family} from Google Fonts."
-
end
-
-
private
-
-
def set_font
-
@font = CustomFont.find(params[:id])
-
end
-
-
def font_params
-
params.require(:custom_font).permit(
-
:name,
-
:family,
-
:source,
-
:url,
-
:fallback,
-
:active,
-
weights: [],
-
styles: []
-
)
-
end
-
-
def font_json(font)
-
{
-
id: font.id,
-
name: font.name,
-
family: font.family,
-
source: font.source,
-
weights: font.weights,
-
styles: font.styles,
-
active: font.active,
-
url: font.font_url
-
}
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::GdprController < Admin::BaseController
-
before_action :set_user, only: [:user_data, :export_user_data, :erase_user_data, :user_consent_history]
-
before_action :set_export_request, only: [:download_export, :export_status]
-
before_action :set_erasure_request, only: [:confirm_erasure, :erasure_status]
-
-
# GET /admin/gdpr
-
def index
-
@users = User.includes(:personal_data_export_requests, :personal_data_erasure_requests)
-
.order(:email)
-
.page(params[:page])
-
.per(25)
-
-
@stats = {
-
total_users: User.count,
-
pending_exports: PersonalDataExportRequest.where(status: ['pending', 'processing']).count,
-
pending_erasures: PersonalDataErasureRequest.where(status: ['pending_confirmation', 'processing']).count,
-
completed_exports: PersonalDataExportRequest.where(status: 'completed').count,
-
completed_erasures: PersonalDataErasureRequest.where(status: 'completed').count
-
}
-
-
@recent_requests = {
-
exports: PersonalDataExportRequest.includes(:user).recent.limit(10),
-
erasures: PersonalDataErasureRequest.includes(:user).recent.limit(10)
-
}
-
end
-
-
# GET /admin/gdpr/users
-
def users
-
@users = User.includes(:personal_data_export_requests, :personal_data_erasure_requests, :user_consents)
-
.order(:email)
-
.page(params[:page])
-
.per(50)
-
-
if params[:search].present?
-
@users = @users.where("email ILIKE ? OR name ILIKE ?", "%#{params[:search]}%", "%#{params[:search]}%")
-
end
-
-
if params[:filter].present?
-
case params[:filter]
-
when 'with_exports'
-
@users = @users.joins(:personal_data_export_requests)
-
when 'with_erasures'
-
@users = @users.joins(:personal_data_erasure_requests)
-
when 'with_consent'
-
@users = @users.joins(:user_consents)
-
end
-
end
-
end
-
-
# GET /admin/gdpr/users/:id
-
def user_data
-
@export_requests = @user.personal_data_export_requests.recent
-
@erasure_requests = @user.personal_data_erasure_requests.recent
-
@consent_records = @user.user_consents.recent
-
-
@data_summary = {
-
posts: @user.posts.count,
-
pages: @user.pages.count,
-
comments: Comment.where(email: @user.email).count,
-
media: @user.media.count,
-
pageviews: Pageview.where(user_id: @user.id).count,
-
api_tokens: @user.api_tokens.count,
-
meta_fields: @user.meta_fields.count,
-
consent_records: @user.user_consents.count
-
}
-
-
@gdpr_status = GdprService.get_user_gdpr_status(@user)
-
end
-
-
# POST /admin/gdpr/users/:id/export
-
def export_user_data
-
begin
-
export_request = GdprService.create_export_request(@user, current_user)
-
-
redirect_to admin_gdpr_user_data_path(@user),
-
notice: "Data export request created successfully. Processing will begin shortly."
-
rescue => e
-
redirect_to admin_gdpr_user_data_path(@user),
-
alert: "Failed to create export request: #{e.message}"
-
end
-
end
-
-
# GET /admin/gdpr/exports/:id/download
-
def download_export
-
unless @export_request.completed?
-
redirect_to admin_gdpr_user_data_path(@export_request.user),
-
alert: 'Export is not ready yet. Please wait for processing to complete.'
-
return
-
end
-
-
unless File.exist?(@export_request.file_path)
-
redirect_to admin_gdpr_user_data_path(@export_request.user),
-
alert: 'Export file not found. Please request a new export.'
-
return
-
end
-
-
send_file @export_request.file_path,
-
filename: "personal_data_#{@export_request.email.gsub('@', '_at_')}_#{Date.today}.json",
-
type: 'application/json',
-
disposition: 'attachment'
-
end
-
-
# POST /admin/gdpr/users/:id/erase
-
def erase_user_data
-
begin
-
erasure_request = GdprService.create_erasure_request(@user, current_user, params[:reason])
-
-
redirect_to admin_gdpr_user_data_path(@user),
-
notice: "Data erasure request created. Confirmation required before processing."
-
rescue => e
-
redirect_to admin_gdpr_user_data_path(@user),
-
alert: "Failed to create erasure request: #{e.message}"
-
end
-
end
-
-
# POST /admin/gdpr/erasures/:id/confirm
-
def confirm_erasure
-
begin
-
GdprService.confirm_erasure_request(@erasure_request, current_user)
-
-
redirect_to admin_gdpr_user_data_path(@erasure_request.user),
-
notice: "Data erasure confirmed and queued for processing."
-
rescue => e
-
redirect_to admin_gdpr_user_data_path(@erasure_request.user),
-
alert: "Failed to confirm erasure: #{e.message}"
-
end
-
end
-
-
# GET /admin/gdpr/users/:id/consent
-
def user_consent_history
-
@consent_records = @user.user_consents.recent
-
@consent_types = UserConsent::CONSENT_TYPES
-
end
-
-
# POST /admin/gdpr/users/:id/consent
-
def record_consent
-
begin
-
consent_data = {
-
granted: params[:granted] == 'true',
-
consent_text: params[:consent_text],
-
ip_address: request.remote_ip,
-
user_agent: request.user_agent
-
}
-
-
GdprService.record_user_consent(@user, params[:consent_type], consent_data)
-
-
redirect_to admin_gdpr_user_consent_history_path(@user),
-
notice: "Consent recorded successfully."
-
rescue => e
-
redirect_to admin_gdpr_user_consent_history_path(@user),
-
alert: "Failed to record consent: #{e.message}"
-
end
-
end
-
-
# DELETE /admin/gdpr/users/:id/consent/:consent_type
-
def withdraw_consent
-
begin
-
GdprService.withdraw_user_consent(@user, params[:consent_type])
-
-
redirect_to admin_gdpr_user_consent_history_path(@user),
-
notice: "Consent withdrawn successfully."
-
rescue => e
-
redirect_to admin_gdpr_user_consent_history_path(@user),
-
alert: "Failed to withdraw consent: #{e.message}"
-
end
-
end
-
-
# GET /admin/gdpr/requests
-
def requests
-
@export_requests = PersonalDataExportRequest.includes(:user)
-
.order(created_at: :desc)
-
.page(params[:export_page])
-
.per(25)
-
-
@erasure_requests = PersonalDataErasureRequest.includes(:user)
-
.order(created_at: :desc)
-
.page(params[:erasure_page])
-
.per(25)
-
-
if params[:status].present?
-
@export_requests = @export_requests.where(status: params[:status])
-
@erasure_requests = @erasure_requests.where(status: params[:status])
-
end
-
-
if params[:user_search].present?
-
user_ids = User.where("email ILIKE ?", "%#{params[:user_search]}%").pluck(:id)
-
@export_requests = @export_requests.where(user_id: user_ids)
-
@erasure_requests = @erasure_requests.where(user_id: user_ids)
-
end
-
end
-
-
# GET /admin/gdpr/audit
-
def audit
-
@audit_entries = GdprService.get_audit_log(params[:page] || 1, 50)
-
-
if params[:user_search].present?
-
# Filter audit entries by user email
-
@audit_entries = @audit_entries.select { |entry|
-
entry[:user_email].downcase.include?(params[:user_search].downcase)
-
}
-
end
-
-
if params[:action_filter].present?
-
@audit_entries = @audit_entries.select { |entry|
-
entry[:action].include?(params[:action_filter])
-
}
-
end
-
end
-
-
# GET /admin/gdpr/compliance
-
def compliance
-
@compliance_stats = {
-
total_users: User.count,
-
users_with_consent: User.joins(:user_consents).distinct.count,
-
pending_requests: PersonalDataExportRequest.where(status: ['pending', 'processing']).count +
-
PersonalDataErasureRequest.where(status: ['pending_confirmation', 'processing']).count,
-
completed_requests: PersonalDataExportRequest.where(status: 'completed').count +
-
PersonalDataErasureRequest.where(status: 'completed').count,
-
consent_types: UserConsent.group(:consent_type).count,
-
recent_activity: {
-
exports_last_7_days: PersonalDataExportRequest.where('created_at > ?', 7.days.ago).count,
-
erasures_last_7_days: PersonalDataErasureRequest.where('created_at > ?', 7.days.ago).count,
-
consent_changes_last_7_days: UserConsent.where('updated_at > ?', 7.days.ago).count
-
}
-
}
-
-
@gdpr_requirements = {
-
data_export_implemented: true,
-
data_erasure_implemented: true,
-
consent_management_implemented: true,
-
audit_trail_implemented: true,
-
data_protection_by_design: true,
-
user_rights_accessible: true
-
}
-
end
-
-
# GET /admin/gdpr/settings
-
def settings
-
@gdpr_settings = {
-
data_retention_days: SiteSetting.get('gdpr_data_retention_days', 365),
-
export_auto_delete_days: SiteSetting.get('gdpr_export_auto_delete_days', 7),
-
erasure_confirmation_required: SiteSetting.get('gdpr_erasure_confirmation_required', true),
-
consent_required_for_processing: SiteSetting.get('gdpr_consent_required_for_processing', true),
-
audit_log_retention_days: SiteSetting.get('gdpr_audit_log_retention_days', 2555), # 7 years
-
anonymize_ip_addresses: SiteSetting.get('gdpr_anonymize_ip_addresses', true)
-
}
-
end
-
-
# PATCH /admin/gdpr/settings
-
def update_settings
-
begin
-
params[:gdpr_settings].each do |key, value|
-
SiteSetting.set(key, value)
-
end
-
-
redirect_to admin_gdpr_settings_path,
-
notice: "GDPR settings updated successfully."
-
rescue => e
-
redirect_to admin_gdpr_settings_path,
-
alert: "Failed to update settings: #{e.message}"
-
end
-
end
-
-
# POST /admin/gdpr/bulk_export
-
def bulk_export
-
user_ids = params[:user_ids] || []
-
-
if user_ids.empty?
-
redirect_to admin_gdpr_users_path,
-
alert: "Please select users to export."
-
return
-
end
-
-
users = User.where(id: user_ids)
-
success_count = 0
-
error_count = 0
-
-
users.each do |user|
-
begin
-
GdprService.create_export_request(user, current_user)
-
success_count += 1
-
rescue => e
-
error_count += 1
-
Rails.logger.error("Bulk export failed for user #{user.id}: #{e.message}")
-
end
-
end
-
-
if error_count > 0
-
redirect_to admin_gdpr_users_path,
-
notice: "Bulk export initiated. #{success_count} successful, #{error_count} failed."
-
else
-
redirect_to admin_gdpr_users_path,
-
notice: "Bulk export initiated for #{success_count} users."
-
end
-
end
-
-
# GET /admin/gdpr/export_template
-
def export_template
-
respond_to do |format|
-
format.json do
-
render json: {
-
template: {
-
request_info: {
-
requested_at: Time.current.iso8601,
-
email: "user@example.com",
-
export_date: Time.current.iso8601
-
},
-
user_profile: {
-
id: 1,
-
email: "user@example.com",
-
name: "User Name",
-
role: "author",
-
created_at: Time.current.iso8601,
-
updated_at: Time.current.iso8601
-
},
-
posts: [],
-
comments: [],
-
media: [],
-
subscribers: [],
-
api_tokens: [],
-
meta_fields: [],
-
analytics_data: {},
-
consent_records: [],
-
gdpr_requests: {},
-
metadata: {
-
total_posts: 0,
-
total_comments: 0,
-
export_date: Time.current.iso8601
-
}
-
}
-
}
-
end
-
end
-
end
-
-
private
-
-
def set_user
-
@user = User.find(params[:id] || params[:user_id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to admin_gdpr_users_path, alert: 'User not found.'
-
end
-
-
def set_export_request
-
@export_request = PersonalDataExportRequest.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to admin_gdpr_requests_path, alert: 'Export request not found.'
-
end
-
-
def set_erasure_request
-
@erasure_request = PersonalDataErasureRequest.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to admin_gdpr_requests_path, alert: 'Erasure request not found.'
-
end
-
end
-
class Admin::GeolocationSettingsController < Admin::BaseController
-
before_action :load_geolocation_settings
-
-
def show
-
@geolocation_service = GeolocationService.instance
-
@maxmind_updater = MaxmindUpdaterService.instance
-
@database_info = @maxmind_updater.database_info
-
@pronviders = GeolocationService::PROVIDERS
-
end
-
-
def update
-
begin
-
# Update geolocation settings
-
geolocation_params.each do |key, value|
-
SiteSetting.set(key, value)
-
end
-
-
# Handle MaxMind database updates
-
if params[:update_databases].present?
-
results = @maxmind_updater.update_all_databases
-
flash[:notice] = "Geolocation settings updated. Database update results: #{format_update_results(results)}"
-
else
-
flash[:notice] = "Geolocation settings updated successfully"
-
end
-
-
redirect_to admin_geolocation_settings_path
-
rescue => e
-
flash[:alert] = "Failed to update settings: #{e.message}"
-
redirect_to admin_geolocation_settings_path
-
end
-
end
-
-
def test_lookup
-
ip_address = params[:test_ip] || '8.8.8.8'
-
result = @geolocation_service.test_lookup(ip_address)
-
-
render json: result
-
end
-
-
def update_maxmind
-
type = params[:type] || 'country'
-
result = @maxmind_updater.update_database(type)
-
-
render json: result
-
end
-
-
def test_connection
-
result = @maxmind_updater.test_connection
-
render json: result
-
end
-
-
def schedule_auto_update
-
result = @maxmind_updater.schedule_auto_update
-
render json: result
-
end
-
-
private
-
-
def load_geolocation_settings
-
@settings = {
-
# Geolocation provider settings
-
geolocation_provider: SiteSetting.get('geolocation_provider', 'maxmind'),
-
geolocation_enabled: SiteSetting.get('geolocation_enabled', false),
-
-
# MaxMind settings
-
maxmind_license_key: SiteSetting.get('maxmind_license_key', ''),
-
maxmind_auto_update: SiteSetting.get('maxmind_auto_update', false),
-
maxmind_auto_update_frequency: SiteSetting.get('maxmind_auto_update_frequency', 'weekly'),
-
-
# IP-API settings
-
geolocation_ipapi_enabled: SiteSetting.get('geolocation_ipapi_enabled', false),
-
-
# IPInfo settings
-
geolocation_ipinfo_enabled: SiteSetting.get('geolocation_ipinfo_enabled', false),
-
geolocation_ipinfo_api_key: SiteSetting.get('geolocation_ipinfo_api_key', ''),
-
-
# IP Geolocation settings
-
geolocation_ipgeolocation_enabled: SiteSetting.get('geolocation_ipgeolocation_enabled', false),
-
geolocation_ipgeolocation_api_key: SiteSetting.get('geolocation_ipgeolocation_api_key', ''),
-
-
# Abstract API settings
-
geolocation_abstract_enabled: SiteSetting.get('geolocation_abstract_enabled', false),
-
geolocation_abstract_api_key: SiteSetting.get('geolocation_abstract_api_key', ''),
-
-
# Privacy settings (GDPR-friendly defaults)
-
geolocation_anonymize_ip: SiteSetting.get('geolocation_anonymize_ip', true),
-
geolocation_store_full_ip: SiteSetting.get('geolocation_store_full_ip', false),
-
geolocation_require_consent: SiteSetting.get('geolocation_require_consent', true),
-
geolocation_consent_message: SiteSetting.get('geolocation_consent_message', ''),
-
geolocation_legal_basis: SiteSetting.get('geolocation_legal_basis', 'consent'),
-
geolocation_data_retention_days: SiteSetting.get('geolocation_data_retention_days', 90),
-
geolocation_auto_delete: SiteSetting.get('geolocation_auto_delete', true),
-
-
# Data collection controls (GDPR-friendly defaults)
-
geolocation_collect_country: SiteSetting.get('geolocation_collect_country', true),
-
geolocation_collect_region: SiteSetting.get('geolocation_collect_region', false),
-
geolocation_collect_city: SiteSetting.get('geolocation_collect_city', false),
-
geolocation_collect_coordinates: SiteSetting.get('geolocation_collect_coordinates', false),
-
-
# Power user settings (disabled by default)
-
geolocation_full_power_mode: SiteSetting.get('geolocation_full_power_mode', false),
-
geolocation_debug_mode: SiteSetting.get('geolocation_debug_mode', false),
-
geolocation_precision_mode: SiteSetting.get('geolocation_precision_mode', false),
-
-
# Fallback settings
-
geolocation_fallback_enabled: SiteSetting.get('geolocation_fallback_enabled', true),
-
geolocation_cache_duration: SiteSetting.get('geolocation_cache_duration', 24) # hours
-
}
-
end
-
-
def geolocation_params
-
params.require(:settings).permit(
-
:geolocation_provider,
-
:geolocation_enabled,
-
:maxmind_license_key,
-
:maxmind_auto_update,
-
:maxmind_auto_update_frequency,
-
:geolocation_ipapi_enabled,
-
:geolocation_ipinfo_enabled,
-
:geolocation_ipinfo_api_key,
-
:geolocation_ipgeolocation_enabled,
-
:geolocation_ipgeolocation_api_key,
-
:geolocation_abstract_enabled,
-
:geolocation_abstract_api_key,
-
:geolocation_anonymize_ip,
-
:geolocation_store_full_ip,
-
:geolocation_require_consent,
-
:geolocation_consent_message,
-
:geolocation_legal_basis,
-
:geolocation_data_retention_days,
-
:geolocation_auto_delete,
-
:geolocation_collect_country,
-
:geolocation_collect_region,
-
:geolocation_collect_city,
-
:geolocation_collect_coordinates,
-
:geolocation_full_power_mode,
-
:geolocation_debug_mode,
-
:geolocation_precision_mode,
-
:geolocation_fallback_enabled,
-
:geolocation_cache_duration
-
)
-
end
-
-
def format_update_results(results)
-
messages = []
-
results.each do |type, result|
-
status = result[:success] ? '✓' : '✗'
-
messages << "#{status} #{type.capitalize}: #{result[:message]}"
-
end
-
messages.join(', ')
-
end
-
-
# POST /admin/settings/geolocation/schedule_auto_update
-
def schedule_auto_update
-
frequency = params[:frequency] || 'weekly'
-
-
begin
-
MaxmindUpdaterService.schedule_auto_update(frequency)
-
redirect_to admin_geolocation_settings_path, notice: "MaxMind auto-update scheduled for #{frequency} updates."
-
rescue => e
-
redirect_to admin_geolocation_settings_path, alert: "Failed to schedule auto-update: #{e.message}"
-
end
-
end
-
-
# DELETE /admin/settings/geolocation/disable_auto_update
-
def disable_auto_update
-
begin
-
MaxmindUpdaterService.disable_auto_update
-
redirect_to admin_geolocation_settings_path, notice: "MaxMind auto-update disabled."
-
rescue => e
-
redirect_to admin_geolocation_settings_path, alert: "Failed to disable auto-update: #{e.message}"
-
end
-
end
-
-
# GET /admin/settings/geolocation/schedule_status
-
def schedule_status
-
schedule_info = MaxmindUpdaterService.get_update_schedule_info
-
-
render json: {
-
enabled: schedule_info[:enabled],
-
frequency: schedule_info[:frequency],
-
next_run: schedule_info[:next_run]&.strftime('%Y-%m-%d %H:%M:%S'),
-
last_run: schedule_info[:last_run]&.strftime('%Y-%m-%d %H:%M:%S'),
-
cron_schedule: schedule_info[:cron_schedule]
-
}
-
end
-
end
-
class Admin::ImageOptimizationAnalyticsController < Admin::BaseController
-
before_action :ensure_admin
-
-
# GET /admin/media/optimization_analytics
-
def index
-
@stats = calculate_overview_stats
-
@recent_optimizations = ImageOptimizationLog.recent.limit(50).includes(:medium, :upload, :user)
-
@compression_level_stats = ImageOptimizationLog.compression_level_stats
-
@optimization_type_stats = ImageOptimizationLog.optimization_type_stats
-
@daily_stats = ImageOptimizationLog.daily_stats(30)
-
end
-
-
# GET /admin/media/optimization_analytics/report
-
def report
-
start_date = params[:start_date]&.to_date || 30.days.ago.to_date
-
end_date = params[:end_date]&.to_date || Date.current
-
-
@report = ImageOptimizationLog.generate_report(start_date, end_date)
-
@start_date = start_date
-
@end_date = end_date
-
-
respond_to do |format|
-
format.html
-
format.json { render json: @report }
-
format.csv do
-
csv_data = ImageOptimizationLog.export_to_csv(start_date, end_date)
-
send_data csv_data,
-
filename: "image_optimization_report_#{start_date}_to_#{end_date}.csv",
-
type: 'text/csv'
-
end
-
end
-
end
-
-
# GET /admin/media/optimization_analytics/failed
-
def failed
-
@failed_optimizations = ImageOptimizationLog.failed_optimizations
-
.includes(:medium, :upload, :user)
-
.page(params[:page])
-
.per(20)
-
end
-
-
# GET /admin/media/optimization_analytics/top_savings
-
def top_savings
-
@top_savings = ImageOptimizationLog.top_savings(50).includes(:medium, :upload, :user)
-
end
-
-
# GET /admin/media/optimization_analytics/user_stats
-
def user_stats
-
@user_stats = ImageOptimizationLog.user_stats
-
@top_users = @user_stats.sort_by { |_, count| -count }.first(20)
-
end
-
-
# GET /admin/media/optimization_analytics/tenant_stats
-
def tenant_stats
-
@tenant_stats = ImageOptimizationLog.tenant_stats
-
@top_tenants = @tenant_stats.sort_by { |_, count| -count }.first(20)
-
end
-
-
# GET /admin/media/optimization_analytics/compression_levels
-
def compression_levels
-
@compression_levels = ImageOptimizationService.available_compression_levels
-
@level_stats = ImageOptimizationLog.compression_level_stats
-
end
-
-
# GET /admin/media/optimization_analytics/performance
-
def performance
-
@avg_processing_time = ImageOptimizationLog.average_processing_time
-
@avg_size_reduction = ImageOptimizationLog.average_size_reduction
-
@total_processing_time = ImageOptimizationLog.total_processing_time
-
@total_bytes_saved = ImageOptimizationLog.total_bytes_saved
-
end
-
-
# DELETE /admin/media/optimization_analytics/clear_logs
-
def clear_logs
-
if params[:confirm] == 'yes'
-
ImageOptimizationLog.delete_all
-
redirect_to admin_image_optimization_analytics_index_path,
-
notice: 'All optimization logs have been cleared.'
-
else
-
redirect_to admin_image_optimization_analytics_index_path,
-
alert: 'Log clearing cancelled. Use confirm=yes to clear logs.'
-
end
-
end
-
-
# GET /admin/media/optimization_analytics/export
-
def export
-
start_date = params[:start_date]&.to_date || 30.days.ago.to_date
-
end_date = params[:end_date]&.to_date || Date.current
-
-
csv_data = ImageOptimizationLog.export_to_csv(start_date, end_date)
-
-
send_data csv_data,
-
filename: "image_optimization_export_#{start_date}_to_#{end_date}.csv",
-
type: 'text/csv'
-
end
-
-
private
-
-
def calculate_overview_stats
-
total_bytes_saved = ImageOptimizationLog.total_bytes_saved || 0
-
avg_reduction = ImageOptimizationLog.average_size_reduction || 0
-
avg_processing = ImageOptimizationLog.average_processing_time || 0
-
-
{
-
total_optimizations: ImageOptimizationLog.count,
-
successful_optimizations: ImageOptimizationLog.successful.count,
-
failed_optimizations: ImageOptimizationLog.failed.count,
-
skipped_optimizations: ImageOptimizationLog.skipped.count,
-
total_bytes_saved: total_bytes_saved,
-
total_size_saved_mb: (total_bytes_saved / 1024.0 / 1024.0).round(2),
-
average_size_reduction: avg_reduction.round(2),
-
average_processing_time: avg_processing.round(3),
-
today_optimizations: ImageOptimizationLog.today.count,
-
this_week_optimizations: ImageOptimizationLog.this_week.count,
-
this_month_optimizations: ImageOptimizationLog.this_month.count
-
}
-
end
-
end
-
class Admin::IntegrationsController < Admin::BaseController
-
before_action :ensure_admin
-
-
# GET /admin/integrations
-
def index
-
@integration_plugins = Plugin.where(name: integration_plugin_names).order(:name)
-
@available_integrations = available_integrations_list
-
end
-
-
# GET /admin/integrations/uploadcare
-
def uploadcare
-
@plugin = Plugin.find_by(name: 'Uploadcare')
-
-
unless @plugin
-
redirect_to admin_integrations_path, alert: 'Uploadcare plugin not found. Please install it first.'
-
return
-
end
-
-
# Load plugin instance
-
@plugin_instance = load_plugin_instance(@plugin)
-
@dashboard_url = @plugin_instance&.dashboard_url
-
@widget_config = @plugin_instance&.widget_config || {}
-
@enabled = @plugin_instance&.enabled? || false
-
end
-
-
# GET /admin/integrations/:name
-
def show
-
integration_name = params[:name]&.titleize
-
@plugin = Plugin.find_by(name: integration_name)
-
-
unless @plugin
-
redirect_to admin_integrations_path, alert: "#{integration_name} integration not found."
-
return
-
end
-
-
@plugin_instance = load_plugin_instance(@plugin)
-
-
# Redirect to specific integration view if available
-
if respond_to?("#{params[:name]}_integration", true)
-
send("#{params[:name]}_integration")
-
else
-
render :show
-
end
-
end
-
-
private
-
-
def ensure_admin
-
unless current_user&.administrator?
-
redirect_to root_path, alert: 'Access denied. Administrator privileges required.'
-
end
-
end
-
-
def integration_plugin_names
-
[
-
'Uploadcare',
-
'Cloudinary',
-
'AWS S3',
-
'Google Analytics',
-
'Mailchimp',
-
'Stripe',
-
'SendGrid',
-
'Twilio',
-
'Slack'
-
]
-
end
-
-
def available_integrations_list
-
[
-
{
-
name: 'Uploadcare',
-
description: 'Professional media management and CDN',
-
icon: '📸',
-
category: 'Media',
-
status: plugin_status('Uploadcare'),
-
url: uploadcare_admin_integrations_path,
-
features: ['File Upload Widget', 'CDN Delivery', 'Image Transformations', 'Dashboard Integration']
-
},
-
{
-
name: 'Cloudinary',
-
description: 'Cloud-based image and video management',
-
icon: '☁️',
-
category: 'Media',
-
status: 'available',
-
url: '#',
-
features: ['Image/Video Upload', 'AI-powered Transformations', 'DAM', 'CDN']
-
},
-
{
-
name: 'AWS S3',
-
description: 'Amazon S3 storage integration',
-
icon: '🪣',
-
category: 'Storage',
-
status: 'available',
-
url: '#',
-
features: ['Object Storage', 'Versioning', 'Backup', 'CloudFront CDN']
-
},
-
{
-
name: 'Google Analytics',
-
description: 'Web analytics and reporting',
-
icon: '📊',
-
category: 'Analytics',
-
status: 'available',
-
url: '#',
-
features: ['GA4 Tracking', 'Custom Events', 'Reports', 'User Behavior']
-
},
-
{
-
name: 'Mailchimp',
-
description: 'Email marketing and automation',
-
icon: '📧',
-
category: 'Marketing',
-
status: 'available',
-
url: '#',
-
features: ['Email Campaigns', 'Lists', 'Automation', 'Reports']
-
},
-
{
-
name: 'Stripe',
-
description: 'Payment processing',
-
icon: '💳',
-
category: 'Payments',
-
status: 'available',
-
url: '#',
-
features: ['Online Payments', 'Subscriptions', 'Invoicing', 'Webhooks']
-
}
-
]
-
end
-
-
def plugin_status(plugin_name)
-
plugin = Plugin.find_by(name: plugin_name)
-
return 'not_installed' unless plugin
-
return 'active' if plugin.active?
-
'installed'
-
end
-
-
def load_plugin_instance(plugin)
-
# Try to get from plugin system
-
instance = Railspress::PluginSystem.get_plugin(plugin.name.underscore) rescue nil
-
return instance if instance
-
-
# Try to load manually
-
plugin_path = Rails.root.join('lib', 'plugins', plugin.name.underscore, "#{plugin.name.underscore}.rb")
-
if File.exist?(plugin_path)
-
begin
-
load plugin_path
-
plugin_class_name = plugin.name.classify
-
plugin_class = plugin_class_name.constantize rescue nil
-
instance = plugin_class.new if plugin_class && plugin_class.ancestors.include?(Railspress::PluginBase)
-
rescue => e
-
Rails.logger.error "Failed to load plugin: #{e.message}"
-
end
-
end
-
-
instance
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::LogsController < Admin::BaseController
-
# GET /admin/logs
-
def index
-
@log_files = available_log_files
-
@current_log = params[:file] || 'development'
-
end
-
-
# GET /admin/logs/stream
-
def stream
-
log_file = params[:file] || 'development'
-
log_path = Rails.root.join('log', "#{log_file}.log")
-
-
unless File.exist?(log_path)
-
render plain: "Log file not found: #{log_file}.log", status: :not_found
-
return
-
end
-
-
# Set headers for Server-Sent Events
-
response.headers['Content-Type'] = 'text/event-stream'
-
response.headers['Cache-Control'] = 'no-cache'
-
response.headers['X-Accel-Buffering'] = 'no'
-
-
# Stream the log file
-
lines = params[:lines]&.to_i || 100
-
-
self.response_body = Enumerator.new do |yielder|
-
begin
-
# Send initial tail of the log
-
initial_lines = tail_file(log_path, lines)
-
yielder << "data: #{initial_lines.to_json}\n\n"
-
-
# Watch for new lines
-
File.open(log_path, 'r') do |file|
-
file.seek(0, IO::SEEK_END)
-
-
loop do
-
line = file.gets
-
-
if line
-
yielder << "data: #{line.to_json}\n\n"
-
else
-
sleep 0.5
-
# Check if file was rotated
-
file.seek(0, IO::SEEK_CUR)
-
end
-
end
-
end
-
rescue IOError
-
# Client disconnected
-
end
-
end
-
end
-
-
# GET /admin/logs/download
-
def download
-
log_file = params[:file] || 'development'
-
log_path = Rails.root.join('log', "#{log_file}.log")
-
-
unless File.exist?(log_path)
-
redirect_to admin_logs_path, alert: "Log file not found: #{log_file}.log"
-
return
-
end
-
-
send_file log_path,
-
filename: "#{log_file}-#{Date.today}.log",
-
type: 'text/plain',
-
disposition: 'attachment'
-
end
-
-
# DELETE /admin/logs/clear
-
def clear
-
log_file = params[:file] || 'development'
-
log_path = Rails.root.join('log', "#{log_file}.log")
-
-
if File.exist?(log_path)
-
File.truncate(log_path, 0)
-
redirect_to admin_logs_path(file: log_file), notice: "Log file cleared: #{log_file}.log"
-
else
-
redirect_to admin_logs_path, alert: "Log file not found: #{log_file}.log"
-
end
-
end
-
-
# GET /admin/logs/search
-
def search
-
log_file = params[:file] || 'development'
-
query = params[:q]
-
log_path = Rails.root.join('log', "#{log_file}.log")
-
-
unless File.exist?(log_path)
-
render json: { error: 'Log file not found' }, status: :not_found
-
return
-
end
-
-
results = []
-
line_number = 0
-
-
File.foreach(log_path) do |line|
-
line_number += 1
-
if line.downcase.include?(query.downcase)
-
results << { line_number: line_number, content: line.strip }
-
break if results.size >= 100 # Limit results
-
end
-
end
-
-
render json: { results: results, query: query, count: results.size }
-
end
-
-
private
-
-
def available_log_files
-
log_dir = Rails.root.join('log')
-
Dir.glob(log_dir.join('*.log')).map do |file|
-
basename = File.basename(file, '.log')
-
-
# Skip weird log files that contain commands or special characters
-
next if basename.include?('puts') || basename.include?(';') || basename.length > 50
-
-
{
-
name: basename,
-
path: file,
-
size: File.size(file),
-
modified: File.mtime(file)
-
}
-
end.compact.sort_by { |f| f[:modified] }.reverse
-
end
-
-
def tail_file(file_path, lines = 100)
-
content = []
-
-
File.open(file_path, 'r') do |file|
-
file.seek(0, IO::SEEK_END)
-
position = file.pos
-
line_count = 0
-
-
# Go backwards through the file
-
while position > 0 && line_count < lines
-
position -= 1
-
file.seek(position)
-
char = file.read(1)
-
-
if char == "\n"
-
line_count += 1
-
break if line_count >= lines
-
end
-
end
-
-
content = file.read.split("\n")
-
end
-
-
content.last(lines)
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::MediaController < Admin::BaseController
-
before_action :set_medium, only: %i[ show edit update destroy ]
-
-
# GET /admin/media or /admin/media.json
-
def index
-
@media = Medium.kept.includes(:user, :upload).order(created_at: :desc)
-
-
# Show trashed if explicitly requested
-
if params[:show_trash] == 'true'
-
@media = Medium.trashed.includes(:user, :upload).order(deleted_at: :desc)
-
end
-
-
respond_to do |format|
-
format.html do
-
# For the new grid view, we just need the media objects
-
# No need for complex JSON data structure
-
end
-
format.json { render json: media_json }
-
end
-
end
-
-
# POST /admin/media/bulk_upload
-
def bulk_upload
-
uploaded_files = []
-
errors = []
-
-
if params[:media].present?
-
params[:media].each do |index, media_params|
-
medium = Medium.new(
-
title: media_params[:title],
-
user: current_user
-
)
-
-
if media_params[:file].present?
-
medium.file.attach(media_params[:file])
-
-
if medium.save
-
uploaded_files << medium
-
else
-
errors << "#{media_params[:file].original_filename}: #{medium.errors.full_messages.join(', ')}"
-
end
-
end
-
end
-
end
-
-
respond_to do |format|
-
if errors.empty?
-
format.json { render json: { success: true, message: "Successfully uploaded #{uploaded_files.count} file(s)" } }
-
else
-
format.json { render json: { success: false, message: errors.join('; ') }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# GET /admin/media/1 or /admin/media/1.json
-
def show
-
end
-
-
# GET /admin/media/new
-
def new
-
@medium = Medium.new
-
end
-
-
# GET /admin/media/1/edit
-
def edit
-
end
-
-
# POST /admin/media or /admin/media.json
-
def create
-
@medium = Medium.new(medium_params)
-
-
respond_to do |format|
-
if @medium.save
-
format.html { redirect_to [:admin, @medium], notice: "Medium was successfully created." }
-
format.json { render :show, status: :created, location: @medium }
-
else
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @medium.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/media/1 or /admin/media/1.json
-
def update
-
respond_to do |format|
-
if @medium.update(medium_params)
-
format.html { redirect_to [:admin, @medium], notice: "Medium was successfully updated.", status: :see_other }
-
format.json { render :show, status: :ok, location: @medium }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @medium.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/media/1 or /admin/media/1.json
-
def destroy
-
if @medium.trashed?
-
@medium.destroy_permanently! # Permanent delete
-
notice = "Media was permanently deleted."
-
else
-
@medium.trash!(current_user) # Soft delete
-
notice = "Media was moved to trash."
-
end
-
-
respond_to do |format|
-
format.html { redirect_to admin_media_path, notice: notice, status: :see_other }
-
format.json { head :no_content }
-
end
-
end
-
-
private
-
# Use callbacks to share common setup or constraints between actions.
-
def set_medium
-
@medium = Medium.find(params[:id])
-
end
-
-
# Only allow a list of trusted parameters through.
-
def medium_params
-
params.fetch(:medium, {})
-
end
-
-
def media_json
-
@media.includes(:upload).map do |medium|
-
{
-
id: medium.id,
-
filename: medium.filename,
-
title: medium.title,
-
file_type: medium.content_type,
-
file_size: medium.file_size,
-
thumbnail_url: medium.image? ? medium.url : nil,
-
quarantined: medium.quarantined?,
-
quarantine_reason: medium.quarantine_reason,
-
created_at: medium.created_at.iso8601,
-
edit_url: edit_admin_medium_path(medium),
-
show_url: admin_medium_path(medium),
-
delete_url: admin_medium_path(medium)
-
}
-
end
-
end
-
end
-
class Admin::MenusController < Admin::BaseController
-
before_action :set_menu, only: %i[ show edit update destroy ]
-
-
# GET /admin/menus or /admin/menus.json
-
def index
-
@menus = Menu.all
-
end
-
-
# GET /admin/menus/1 or /admin/menus/1.json
-
def show
-
end
-
-
# GET /admin/menus/new
-
def new
-
@menu = Menu.new
-
end
-
-
# GET /admin/menus/1/edit
-
def edit
-
end
-
-
# POST /admin/menus or /admin/menus.json
-
def create
-
@menu = Menu.new(menu_params)
-
-
respond_to do |format|
-
if @menu.save
-
format.html { redirect_to [:admin, @menu], notice: "Menu was successfully created." }
-
format.json { render :show, status: :created, location: @menu }
-
else
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @menu.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/menus/1 or /admin/menus/1.json
-
def update
-
respond_to do |format|
-
if @menu.update(menu_params)
-
format.html { redirect_to [:admin, @menu], notice: "Menu was successfully updated.", status: :see_other }
-
format.json { render :show, status: :ok, location: @menu }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @menu.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/menus/1 or /admin/menus/1.json
-
def destroy
-
@menu.destroy!
-
-
respond_to do |format|
-
format.html { redirect_to admin_menus_path, notice: "Menu was successfully destroyed.", status: :see_other }
-
format.json { head :no_content }
-
end
-
end
-
-
private
-
# Use callbacks to share common setup or constraints between actions.
-
def set_menu
-
@menu = Menu.find(params[:id])
-
end
-
-
# Only allow a list of trusted parameters through.
-
def menu_params
-
params.fetch(:menu, {})
-
end
-
end
-
class Admin::OauthController < Admin::BaseController
-
before_action :ensure_admin
-
-
# GET /admin/settings/oauth
-
def index
-
load_oauth_settings
-
end
-
-
# PATCH /admin/settings/oauth
-
def update
-
# Update OAuth settings
-
if params[:settings]
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
end
-
-
# Update provider-specific settings
-
update_provider_settings
-
-
redirect_to admin_oauth_settings_path, notice: 'OAuth settings updated successfully.'
-
end
-
-
# POST /admin/settings/oauth/test_connection
-
def test_connection
-
provider = params[:provider]
-
-
begin
-
case provider
-
when 'google'
-
test_google_connection
-
when 'github'
-
test_github_connection
-
when 'facebook'
-
test_facebook_connection
-
when 'twitter'
-
test_twitter_connection
-
else
-
render json: { success: false, message: 'Unknown provider' }, status: :bad_request
-
return
-
end
-
rescue => e
-
render json: {
-
success: false,
-
message: "Connection test failed: #{e.message}"
-
}, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def load_oauth_settings
-
@settings = {
-
# Google OAuth
-
google_enabled: SiteSetting.get('google_oauth_enabled', false),
-
google_client_id: SiteSetting.get('google_oauth_client_id', ''),
-
google_client_secret: SiteSetting.get('google_oauth_client_secret', ''),
-
google_redirect_uri: SiteSetting.get('google_oauth_redirect_uri', ''),
-
google_tenant: SiteSetting.get('google_oauth_tenant', ''),
-
-
# GitHub OAuth
-
github_enabled: SiteSetting.get('github_oauth_enabled', false),
-
github_client_id: SiteSetting.get('github_oauth_client_id', ''),
-
github_client_secret: SiteSetting.get('github_oauth_client_secret', ''),
-
github_redirect_uri: SiteSetting.get('github_oauth_redirect_uri', ''),
-
-
# Facebook OAuth
-
facebook_enabled: SiteSetting.get('facebook_oauth_enabled', false),
-
facebook_app_id: SiteSetting.get('facebook_oauth_app_id', ''),
-
facebook_app_secret: SiteSetting.get('facebook_oauth_app_secret', ''),
-
facebook_redirect_uri: SiteSetting.get('facebook_oauth_redirect_uri', ''),
-
-
# Twitter OAuth
-
twitter_enabled: SiteSetting.get('twitter_oauth_enabled', false),
-
twitter_api_key: SiteSetting.get('twitter_oauth_api_key', ''),
-
twitter_api_secret: SiteSetting.get('twitter_oauth_api_secret', ''),
-
twitter_redirect_uri: SiteSetting.get('twitter_oauth_redirect_uri', ''),
-
-
# General OAuth settings
-
oauth_auto_register: SiteSetting.get('oauth_auto_register', true),
-
oauth_default_role: SiteSetting.get('oauth_default_role', 'subscriber'),
-
oauth_require_email: SiteSetting.get('oauth_require_email', true),
-
oauth_allow_existing_users: SiteSetting.get('oauth_allow_existing_users', true)
-
}
-
end
-
-
def update_provider_settings
-
# Update Google OAuth settings
-
if params[:google_client_id].present?
-
SiteSetting.set('google_oauth_client_id', params[:google_client_id], 'string')
-
end
-
if params[:google_client_secret].present?
-
SiteSetting.set('google_oauth_client_secret', params[:google_client_secret], 'string')
-
end
-
if params[:google_redirect_uri].present?
-
SiteSetting.set('google_oauth_redirect_uri', params[:google_redirect_uri], 'string')
-
end
-
if params[:google_tenant].present?
-
SiteSetting.set('google_oauth_tenant', params[:google_tenant], 'string')
-
end
-
-
# Update GitHub OAuth settings
-
if params[:github_client_id].present?
-
SiteSetting.set('github_oauth_client_id', params[:github_client_id], 'string')
-
end
-
if params[:github_client_secret].present?
-
SiteSetting.set('github_oauth_client_secret', params[:github_client_secret], 'string')
-
end
-
if params[:github_redirect_uri].present?
-
SiteSetting.set('github_oauth_redirect_uri', params[:github_redirect_uri], 'string')
-
end
-
-
# Update Facebook OAuth settings
-
if params[:facebook_app_id].present?
-
SiteSetting.set('facebook_oauth_app_id', params[:facebook_app_id], 'string')
-
end
-
if params[:facebook_app_secret].present?
-
SiteSetting.set('facebook_oauth_app_secret', params[:facebook_app_secret], 'string')
-
end
-
if params[:facebook_redirect_uri].present?
-
SiteSetting.set('facebook_oauth_redirect_uri', params[:facebook_redirect_uri], 'string')
-
end
-
-
# Update Twitter OAuth settings
-
if params[:twitter_api_key].present?
-
SiteSetting.set('twitter_oauth_api_key', params[:twitter_api_key], 'string')
-
end
-
if params[:twitter_api_secret].present?
-
SiteSetting.set('twitter_oauth_api_secret', params[:twitter_api_secret], 'string')
-
end
-
if params[:twitter_redirect_uri].present?
-
SiteSetting.set('twitter_oauth_redirect_uri', params[:twitter_redirect_uri], 'string')
-
end
-
end
-
-
def setting_type_for(key)
-
boolean_settings = %w[
-
google_oauth_enabled github_oauth_enabled facebook_oauth_enabled twitter_oauth_enabled
-
oauth_auto_register oauth_require_email oauth_allow_existing_users
-
]
-
-
if boolean_settings.include?(key)
-
'boolean'
-
else
-
'string'
-
end
-
end
-
-
def test_google_connection
-
client_id = SiteSetting.get('google_oauth_client_id', '')
-
client_secret = SiteSetting.get('google_oauth_client_secret', '')
-
-
if client_id.blank? || client_secret.blank?
-
render json: { success: false, message: 'Google OAuth credentials not configured' }
-
return
-
end
-
-
begin
-
# Test Google OAuth by making a request to Google's token info endpoint
-
# This validates that the client_id and client_secret are valid
-
require 'net/http'
-
require 'uri'
-
-
# Create a test token request to validate credentials
-
uri = URI('https://oauth2.googleapis.com/token')
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
-
request = Net::HTTP::Post.new(uri)
-
request.set_form_data({
-
'client_id' => client_id,
-
'client_secret' => client_secret,
-
'grant_type' => 'authorization_code',
-
'code' => 'test_code', # This will fail but we can check the error type
-
'redirect_uri' => SiteSetting.get('google_oauth_redirect_uri', 'http://localhost:3000/auth/google/callback')
-
})
-
-
response = http.request(request)
-
-
# If we get an "invalid_grant" error, it means the credentials are valid
-
# but the authorization code is invalid (which is expected for a test)
-
if response.code == '400'
-
body = JSON.parse(response.body)
-
if body['error'] == 'invalid_grant'
-
render json: {
-
success: true,
-
message: 'Google OAuth credentials are valid'
-
}
-
else
-
render json: {
-
success: false,
-
message: "Google OAuth error: #{body['error_description'] || body['error']}"
-
}
-
end
-
else
-
render json: {
-
success: false,
-
message: "Unexpected response from Google: #{response.code}"
-
}
-
end
-
rescue => e
-
render json: {
-
success: false,
-
message: "Google OAuth connection test failed: #{e.message}"
-
}
-
end
-
end
-
-
def test_github_connection
-
client_id = SiteSetting.get('github_oauth_client_id', '')
-
client_secret = SiteSetting.get('github_oauth_client_secret', '')
-
-
if client_id.blank? || client_secret.blank?
-
render json: { success: false, message: 'GitHub OAuth credentials not configured' }
-
return
-
end
-
-
begin
-
# Test GitHub OAuth by making a request to GitHub's API
-
require 'net/http'
-
require 'uri'
-
-
# Create a test token request to validate credentials
-
uri = URI('https://github.com/login/oauth/access_token')
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
-
request = Net::HTTP::Post.new(uri)
-
request['Accept'] = 'application/json'
-
request.set_form_data({
-
'client_id' => client_id,
-
'client_secret' => client_secret,
-
'code' => 'test_code' # This will fail but we can check the error type
-
})
-
-
response = http.request(request)
-
-
# If we get an "incorrect_client_credentials" error, it means the credentials are invalid
-
# If we get an "bad_verification_code" error, it means the credentials are valid
-
if response.code == '200'
-
body = JSON.parse(response.body)
-
if body['error'] == 'bad_verification_code'
-
render json: {
-
success: true,
-
message: 'GitHub OAuth credentials are valid'
-
}
-
else
-
render json: {
-
success: false,
-
message: "GitHub OAuth error: #{body['error_description'] || body['error']}"
-
}
-
end
-
else
-
render json: {
-
success: false,
-
message: "Unexpected response from GitHub: #{response.code}"
-
}
-
end
-
rescue => e
-
render json: {
-
success: false,
-
message: "GitHub OAuth connection test failed: #{e.message}"
-
}
-
end
-
end
-
-
def test_facebook_connection
-
app_id = SiteSetting.get('facebook_oauth_app_id', '')
-
app_secret = SiteSetting.get('facebook_oauth_app_secret', '')
-
-
if app_id.blank? || app_secret.blank?
-
render json: { success: false, message: 'Facebook OAuth credentials not configured' }
-
return
-
end
-
-
begin
-
# Test Facebook OAuth by making a request to Facebook's Graph API
-
require 'net/http'
-
require 'uri'
-
-
# Create a test app token request to validate credentials
-
uri = URI("https://graph.facebook.com/oauth/access_token")
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
-
request = Net::HTTP::Get.new(uri)
-
request.set_form_data({
-
'client_id' => app_id,
-
'client_secret' => app_secret,
-
'grant_type' => 'client_credentials'
-
})
-
-
response = http.request(request)
-
-
if response.code == '200'
-
body = JSON.parse(response.body)
-
if body['access_token'].present?
-
render json: {
-
success: true,
-
message: 'Facebook OAuth credentials are valid'
-
}
-
else
-
render json: {
-
success: false,
-
message: 'Facebook OAuth credentials are invalid'
-
}
-
end
-
else
-
body = JSON.parse(response.body) rescue {}
-
render json: {
-
success: false,
-
message: "Facebook OAuth error: #{body['error']&.dig('message') || 'Invalid credentials'}"
-
}
-
end
-
rescue => e
-
render json: {
-
success: false,
-
message: "Facebook OAuth connection test failed: #{e.message}"
-
}
-
end
-
end
-
-
def test_twitter_connection
-
api_key = SiteSetting.get('twitter_oauth_api_key', '')
-
api_secret = SiteSetting.get('twitter_oauth_api_secret', '')
-
-
if api_key.blank? || api_secret.blank?
-
render json: { success: false, message: 'Twitter OAuth credentials not configured' }
-
return
-
end
-
-
begin
-
# Test Twitter OAuth by making a request to Twitter's API
-
require 'net/http'
-
require 'uri'
-
require 'base64'
-
-
# Create a test bearer token request to validate credentials
-
uri = URI('https://api.twitter.com/oauth2/token')
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
-
# Create basic auth header
-
credentials = Base64.strict_encode64("#{api_key}:#{api_secret}")
-
-
request = Net::HTTP::Post.new(uri)
-
request['Authorization'] = "Basic #{credentials}"
-
request['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8'
-
request.set_form_data({
-
'grant_type' => 'client_credentials'
-
})
-
-
response = http.request(request)
-
-
if response.code == '200'
-
body = JSON.parse(response.body)
-
if body['access_token'].present?
-
render json: {
-
success: true,
-
message: 'Twitter OAuth credentials are valid'
-
}
-
else
-
render json: {
-
success: false,
-
message: 'Twitter OAuth credentials are invalid'
-
}
-
end
-
else
-
body = JSON.parse(response.body) rescue {}
-
render json: {
-
success: false,
-
message: "Twitter OAuth error: #{body['errors']&.first&.dig('message') || 'Invalid credentials'}"
-
}
-
end
-
rescue => e
-
render json: {
-
success: false,
-
message: "Twitter OAuth connection test failed: #{e.message}"
-
}
-
end
-
end
-
end
-
class Admin::PageTemplatesController < Admin::BaseController
-
before_action :set_page_template, only: %i[show edit update destroy toggle duplicate]
-
layout :choose_layout
-
-
# GET /admin/page_templates
-
def index
-
@page_templates = PageTemplate.ordered.includes(:pages)
-
-
respond_to do |format|
-
format.html
-
format.json { render json: page_templates_json }
-
end
-
end
-
-
# GET /admin/page_templates/1
-
def show
-
end
-
-
# GET /admin/page_templates/new
-
def new
-
@page_template = PageTemplate.new(template_type: 'default')
-
end
-
-
# GET /admin/page_templates/1/edit
-
def edit
-
end
-
-
# POST /admin/page_templates
-
def create
-
@page_template = PageTemplate.new(page_template_params)
-
-
respond_to do |format|
-
if @page_template.save
-
format.html { redirect_to [:admin, @page_template], notice: "Page template was successfully created." }
-
format.json { render :show, status: :created, location: @page_template }
-
else
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @page_template.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/page_templates/1
-
def update
-
respond_to do |format|
-
if @page_template.update(page_template_params)
-
format.html { redirect_to [:admin, @page_template], notice: "Page template was successfully updated." }
-
format.json { render :show, status: :ok, location: @page_template }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @page_template.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/page_templates/1
-
def destroy
-
@page_template.destroy!
-
-
respond_to do |format|
-
format.html { redirect_to admin_page_templates_path, notice: "Page template was successfully destroyed." }
-
format.json { head :no_content }
-
end
-
end
-
-
# PATCH /admin/page_templates/1/toggle
-
def toggle
-
@page_template.update(active: !@page_template.active)
-
-
respond_to do |format|
-
format.json { render json: { success: true, active: @page_template.active } }
-
end
-
end
-
-
# POST /admin/page_templates/1/duplicate
-
def duplicate
-
new_template = @page_template.dup
-
new_template.name = "#{@page_template.name} (Copy)"
-
new_template.position = PageTemplate.maximum(:position).to_i + 1
-
-
if new_template.save
-
redirect_to [:admin, new_template], notice: "Page template was successfully duplicated."
-
else
-
redirect_to admin_page_templates_path, alert: "Failed to duplicate page template."
-
end
-
end
-
-
# GET /admin/page_templates/1/customize
-
def customize
-
@page_template = PageTemplate.find(params[:id])
-
render layout: 'grapesjs_fullscreen'
-
end
-
-
# GET /admin/page_templates/1/theme_edit
-
def theme_edit
-
@page_template = PageTemplate.find(params[:id])
-
@current_file_path = "page_templates/#{@page_template.id}/template.html"
-
render layout: 'editor_fullscreen'
-
end
-
-
private
-
-
def set_page_template
-
@page_template = PageTemplate.find(params[:id])
-
end
-
-
def page_template_params
-
params.require(:page_template).permit(
-
:name, :template_type, :html_content, :css_content, :js_content,
-
:active, :position
-
)
-
end
-
-
def page_templates_json
-
@page_templates.map do |template|
-
{
-
id: template.id,
-
name: template.name,
-
template_type: template.template_type,
-
active: template.active,
-
pages_count: template.pages.count,
-
position: template.position,
-
created_at: template.created_at.strftime("%Y-%m-%d %H:%M"),
-
updated_at: template.updated_at.strftime("%Y-%m-%d %H:%M")
-
}
-
end
-
end
-
-
def choose_layout
-
action_name == 'customize' ? 'grapesjs_fullscreen' :
-
action_name == 'theme_edit' ? 'editor_fullscreen' : 'admin'
-
end
-
end
-
-
-
-
-
-
-
-
class Admin::PagesController < Admin::BaseController
-
before_action :set_page, only: %i[ show edit update destroy publish unpublish ]
-
-
# GET /admin/pages or /admin/pages.json
-
def index
-
@pages = Page.kept
-
-
# Filter by status if specified
-
if params[:status].present? && Page.statuses.keys.include?(params[:status])
-
@pages = @pages.where(status: params[:status])
-
end
-
-
# Show trashed if explicitly requested
-
if params[:show_trash] == 'true'
-
@pages = Page.trashed.includes(:user).order(deleted_at: :desc)
-
else
-
@pages = @pages.includes(:user).order(created_at: :desc)
-
end
-
-
respond_to do |format|
-
format.html do
-
@pages_data = pages_json
-
@stats = {
-
total: Page.kept.count,
-
published: Page.published.count,
-
draft: Page.where(status: 'draft').count,
-
trash: Page.trashed.count
-
}
-
@bulk_actions = [
-
{ value: 'trash', label: 'Move to Trash' },
-
{ value: 'untrash', label: 'Restore' },
-
{ value: 'delete', label: 'Delete Permanently' }
-
]
-
@status_options = [
-
{ value: 'published', label: 'Published' },
-
{ value: 'draft', label: 'Draft' },
-
{ value: 'pending', label: 'Pending' }
-
]
-
@columns = [
-
{
-
title: "",
-
formatter: "rowSelection",
-
titleFormatter: "rowSelection",
-
width: 40,
-
headerSort: false
-
},
-
{
-
title: "Title",
-
field: "title",
-
width: 300,
-
formatter: "function(cell, formatterParams) { const data = cell.getRow().getData(); return '<a href=\"' + data.edit_url + '\" class=\"text-indigo-600 hover:text-indigo-900 font-medium\">' + data.title + '</a>'; }"
-
},
-
{
-
title: "Author",
-
field: "author_name",
-
width: 150
-
},
-
{
-
title: "Status",
-
field: "status",
-
width: 100,
-
formatter: "function(cell, formatterParams) { const value = cell.getValue(); const statusMap = { 'published': { class: 'bg-green-100 text-green-800', label: 'Published' }, 'draft': { class: 'bg-yellow-100 text-yellow-800', label: 'Draft' }, 'pending': { class: 'bg-blue-100 text-blue-800', label: 'Pending' }, 'trash': { class: 'bg-red-100 text-red-800', label: 'Trash' } }; const status = statusMap[value] || { class: 'bg-gray-100 text-gray-800', label: value }; return '<span class=\"px-2 py-1 text-xs font-medium rounded-full ' + status.class + '\">' + status.label + '</span>'; }"
-
},
-
{
-
title: "Template",
-
field: "template",
-
width: 120,
-
formatter: "function(cell, formatterParams) { const template = cell.getValue(); return template || '<span class=\"text-gray-400\">Default</span>'; }"
-
},
-
{
-
title: "Date",
-
field: "created_at",
-
width: 150,
-
formatter: "function(cell, formatterParams) { const date = new Date(cell.getValue()); return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); }"
-
},
-
{
-
title: "Actions",
-
field: "actions",
-
width: 120,
-
headerSort: false,
-
formatter: "function(cell, formatterParams) { const data = cell.getRow().getData(); let actions = ''; if (data.edit_url) { actions += '<a href=\"' + data.edit_url + '\" class=\"text-indigo-600 hover:text-indigo-900 mr-2\" title=\"Edit\">✏️</a>'; } if (data.show_url) { actions += '<a href=\"' + data.show_url + '\" class=\"text-blue-600 hover:text-blue-900 mr-2\" title=\"View\">👁️</a>'; } if (data.delete_url) { actions += '<a href=\"' + data.delete_url + '\" class=\"text-red-600 hover:text-red-900\" title=\"Delete\" data-confirm=\"Are you sure?\">🗑️</a>'; } return actions; }"
-
}
-
]
-
end
-
format.json { render json: pages_json }
-
end
-
end
-
-
# GET /admin/pages/1 or /admin/pages/1.json
-
def show
-
end
-
-
# GET /admin/pages/new
-
def new
-
@page = current_user.pages.build(status: :draft)
-
end
-
-
# GET /admin/pages/1/edit
-
def edit
-
end
-
-
# POST /admin/pages or /admin/pages.json
-
def create
-
@page = current_user.pages.build(page_params)
-
-
respond_to do |format|
-
if @page.save
-
format.html { redirect_to [:admin, @page], notice: "Page was successfully created." }
-
format.json { render :show, status: :created, location: [:admin, @page] }
-
else
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @page.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/pages/1 or /admin/pages/1.json
-
def update
-
respond_to do |format|
-
if @page.update(page_params)
-
format.html { redirect_to [:admin, @page], notice: "Page was successfully updated." }
-
format.json { render :show, status: :ok, location: [:admin, @page] }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @page.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/pages/1 or /admin/pages/1.json
-
def destroy
-
if @page.trashed?
-
@page.destroy_permanently! # Permanent delete
-
notice = "Page was permanently deleted."
-
else
-
@page.trash!(current_user) # Soft delete
-
notice = "Page was moved to trash."
-
end
-
-
respond_to do |format|
-
format.html { redirect_to admin_pages_path, notice: notice, status: :see_other }
-
format.json { head :no_content }
-
end
-
end
-
-
# PATCH /admin/pages/1/publish
-
def publish
-
@page.update(status: :published, published_at: Time.current)
-
redirect_to admin_pages_path, notice: "Page was successfully published."
-
end
-
-
# PATCH /admin/pages/1/unpublish
-
def unpublish
-
@page.update(status: :draft)
-
redirect_to admin_pages_path, notice: "Page was unpublished."
-
end
-
-
# POST /admin/pages/bulk_action
-
def bulk_action
-
action_type = params[:action_type]
-
page_ids = params[:page_ids] || []
-
-
pages = Page.where(id: page_ids)
-
-
case action_type
-
when 'publish'
-
pages.update_all(status: :published, published_at: Time.current)
-
message = "#{pages.count} pages published"
-
when 'unpublish'
-
pages.update_all(status: :draft)
-
message = "#{pages.count} pages unpublished"
-
when 'delete'
-
pages.destroy_all
-
message = "#{pages.count} pages deleted"
-
else
-
message = "Invalid action"
-
end
-
-
respond_to do |format|
-
format.json { render json: { success: true, message: message } }
-
end
-
end
-
-
private
-
-
def set_page
-
@page = Page.friendly.find(params[:id])
-
end
-
-
def page_params
-
params.require(:page).permit(
-
:title, :slug, :content, :status, :published_at,
-
:parent_id, :order, :template, :meta_description, :meta_keywords,
-
:password, :password_hint
-
)
-
end
-
-
def pages_json
-
@pages.map do |page|
-
{
-
id: page.id,
-
title: page.title,
-
slug: page.slug,
-
status: page.status,
-
author: page.user.email.split('@').first,
-
created_at: page.created_at.strftime("%Y-%m-%d %H:%M"),
-
published_at: page.published_at&.strftime("%Y-%m-%d %H:%M"),
-
actions: view_context.link_to('Edit', edit_admin_page_path(page), class: 'text-indigo-400 hover:text-indigo-300')
-
}
-
end
-
end
-
end
-
class Admin::PasswordsController < Devise::PasswordsController
-
layout 'admin_login'
-
helper AppearanceHelper
-
-
# Override to redirect to admin login after password reset
-
def after_resetting_password_path_for(resource)
-
new_admin_user_session_path
-
end
-
end
-
class Admin::PixelsController < Admin::BaseController
-
before_action :set_pixel, only: [:edit, :update, :destroy, :toggle, :test]
-
-
# GET /admin/pixels
-
def index
-
@pixels = Pixel.includes(:versions).ordered
-
-
# Filter by status
-
@pixels = @pixels.active if params[:status] == 'active'
-
@pixels = @pixels.inactive if params[:status] == 'inactive'
-
-
# Filter by provider
-
@pixels = @pixels.by_provider(params[:provider]) if params[:provider].present?
-
-
# Filter by position
-
@pixels = @pixels.by_position(params[:position]) if params[:position].present?
-
-
# Stats
-
@stats = {
-
total: Pixel.count,
-
active: Pixel.active.count,
-
inactive: Pixel.inactive.count,
-
providers: Pixel.group(:provider).count.keys.compact.count
-
}
-
end
-
-
# GET /admin/pixels/new
-
def new
-
@pixel = Pixel.new
-
end
-
-
# GET /admin/pixels/:id/edit
-
def edit
-
end
-
-
# POST /admin/pixels
-
def create
-
@pixel = Pixel.new(pixel_params)
-
-
if @pixel.save
-
redirect_to admin_pixels_path, notice: 'Pixel added successfully.'
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /admin/pixels/:id
-
def update
-
if @pixel.update(pixel_params)
-
redirect_to admin_pixels_path, notice: 'Pixel updated successfully.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/pixels/:id
-
def destroy
-
@pixel.destroy
-
redirect_to admin_pixels_path, notice: 'Pixel deleted successfully.'
-
end
-
-
# PATCH /admin/pixels/:id/toggle
-
def toggle
-
@pixel.update(active: !@pixel.active)
-
redirect_to admin_pixels_path, notice: "Pixel #{@pixel.active? ? 'activated' : 'deactivated'}."
-
end
-
-
# GET /admin/pixels/:id/test
-
def test
-
@pixel_code = @pixel.render_code
-
render layout: false
-
end
-
-
# POST /admin/pixels/bulk_action
-
def bulk_action
-
pixel_ids = params[:pixel_ids] || []
-
action = params[:bulk_action]
-
-
case action
-
when 'activate'
-
Pixel.where(id: pixel_ids).update_all(active: true)
-
message = "#{pixel_ids.count} pixels activated."
-
when 'deactivate'
-
Pixel.where(id: pixel_ids).update_all(active: false)
-
message = "#{pixel_ids.count} pixels deactivated."
-
when 'delete'
-
Pixel.where(id: pixel_ids).destroy_all
-
message = "#{pixel_ids.count} pixels deleted."
-
else
-
message = "Invalid action."
-
end
-
-
redirect_to admin_pixels_path, notice: message
-
end
-
-
private
-
-
def set_pixel
-
@pixel = Pixel.find(params[:id])
-
end
-
-
def pixel_params
-
params.require(:pixel).permit(
-
:name,
-
:pixel_type,
-
:provider,
-
:pixel_id,
-
:custom_code,
-
:position,
-
:active,
-
:notes
-
)
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::PluginPagesController < Admin::BaseController
-
before_action :set_plugin
-
before_action :check_capability
-
-
# Show plugin admin page
-
def show
-
@page_slug = params[:page_slug]
-
@admin_page = @plugin.admin_pages.find { |p| p[:slug] == @page_slug }
-
-
unless @admin_page
-
redirect_to admin_plugins_path, alert: "Page not found"
-
return
-
end
-
-
# If the plugin has a custom callback for this page
-
if @admin_page[:callback]
-
@page_data = @plugin.send(@admin_page[:callback])
-
else
-
# Default: render settings page
-
@page_data = @plugin.render_settings_page
-
end
-
end
-
-
# Update plugin settings
-
def update
-
@page_slug = params[:page_slug]
-
-
# Update all submitted settings
-
if params[:settings]
-
params[:settings].each do |key, value|
-
@plugin.set_setting(key.to_sym, value)
-
end
-
-
flash[:notice] = "Settings saved successfully"
-
end
-
-
redirect_to admin_plugin_page_path(plugin_identifier: params[:plugin_identifier], page_slug: @page_slug)
-
end
-
-
# Handle custom actions
-
def action
-
action_name = params[:action_name]
-
-
if @plugin.respond_to?(action_name)
-
result = @plugin.send(action_name, params)
-
render json: { success: true, data: result }
-
else
-
render json: { success: false, error: "Action not found" }, status: 404
-
end
-
end
-
-
private
-
-
def set_plugin
-
plugin_identifier = params[:plugin_identifier]
-
@plugin = Railspress::PluginSystem.get_plugin(plugin_identifier)
-
-
unless @plugin
-
redirect_to admin_plugins_path, alert: "Plugin not found"
-
end
-
end
-
-
def check_capability
-
# Check if user has required capability for this page
-
return if current_user.administrator?
-
-
capability = @plugin.admin_pages.find { |p| p[:slug] == params[:page_slug] }&.dig(:capability)
-
-
case capability
-
when 'editor'
-
ensure_editor_access
-
when 'author'
-
# Authors have basic access
-
else
-
ensure_admin
-
end
-
end
-
end
-
class Admin::PluginsController < Admin::BaseController
-
include PluginSettingsHelper
-
-
before_action :ensure_admin
-
before_action :set_plugin, only: [:show, :edit, :update, :destroy, :activate, :deactivate, :settings]
-
-
# GET /admin/plugins
-
def index
-
# Auto-discover and register plugins from filesystem
-
discover_and_register_plugins
-
-
@installed_plugins = Plugin.all.order(active: :desc, name: :asc)
-
end
-
-
# GET /admin/plugins/browse
-
def browse
-
@available_plugins = fetch_available_plugins
-
@categories = plugin_categories
-
@featured_plugins = @available_plugins.select { |p| p[:featured] }
-
-
# Filter by category
-
if params[:category].present?
-
@available_plugins = @available_plugins.select { |p| p[:category] == params[:category] }
-
end
-
-
# Search
-
if params[:q].present?
-
query = params[:q].downcase
-
@available_plugins = @available_plugins.select do |p|
-
p[:name].downcase.include?(query) ||
-
p[:description].downcase.include?(query) ||
-
p[:tags].any? { |t| t.downcase.include?(query) }
-
end
-
end
-
end
-
-
# GET /admin/plugins/marketplace
-
def marketplace
-
@available_plugins = fetch_available_plugins
-
@categories = plugin_categories
-
@featured_plugins = @available_plugins.select { |p| p[:featured] }
-
@popular_plugins = @available_plugins.sort_by { |p| -p[:downloads] }.first(10)
-
@new_plugins = @available_plugins.select { |p| p[:new] }.first(10)
-
-
# Filter by category
-
if params[:category].present?
-
@available_plugins = @available_plugins.select { |p| p[:category] == params[:category] }
-
end
-
-
# Search
-
if params[:q].present?
-
query = params[:q].downcase
-
@available_plugins = @available_plugins.select do |p|
-
p[:name].downcase.include?(query) ||
-
p[:description].downcase.include?(query) ||
-
p[:tags].any? { |t| t.downcase.include?(query) }
-
end
-
end
-
end
-
-
# GET /admin/plugins/1
-
def show
-
end
-
-
# GET /admin/plugins/new
-
def new
-
@plugin = Plugin.new
-
end
-
-
# GET /admin/plugins/1/edit
-
def edit
-
end
-
-
# POST /admin/plugins
-
def create
-
@plugin = Plugin.new(plugin_params)
-
-
if @plugin.save
-
redirect_to admin_plugins_path, notice: "Plugin was successfully created."
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /admin/plugins/1
-
def update
-
if @plugin.update(plugin_params)
-
redirect_to admin_plugins_path, notice: "Plugin was successfully updated."
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/plugins/1
-
def destroy
-
@plugin.destroy
-
redirect_to admin_plugins_path, notice: "Plugin was successfully deleted."
-
end
-
-
# PATCH /admin/plugins/1/activate
-
def activate
-
if @plugin.activate!
-
# Load the plugin
-
load_plugin(@plugin)
-
-
redirect_to admin_plugins_path, notice: "Plugin '#{@plugin.name}' activated successfully."
-
else
-
redirect_to admin_plugins_path, alert: "Failed to activate plugin."
-
end
-
end
-
-
# PATCH /admin/plugins/1/deactivate
-
def deactivate
-
if @plugin.deactivate!
-
redirect_to admin_plugins_path, notice: "Plugin '#{@plugin.name}' deactivated."
-
else
-
redirect_to admin_plugins_path, alert: "Failed to deactivate plugin."
-
end
-
end
-
-
# POST /admin/plugins/install
-
def install
-
plugin_slug = params[:plugin_slug]
-
plugin_data = fetch_available_plugins.find { |p| p[:slug] == plugin_slug }
-
-
unless plugin_data
-
return redirect_to browse_admin_plugins_path, alert: "Plugin not found."
-
end
-
-
# Check if already installed
-
if Plugin.exists?(name: plugin_data[:name])
-
return redirect_to browse_admin_plugins_path, alert: "Plugin already installed."
-
end
-
-
# Create plugin record
-
plugin = Plugin.create!(
-
name: plugin_data[:name],
-
description: plugin_data[:description],
-
author: plugin_data[:author],
-
version: plugin_data[:version],
-
active: false
-
)
-
-
# In a real implementation, this would download and install the plugin files
-
# For now, we just create the database record
-
-
redirect_to admin_plugins_path, notice: "Plugin '#{plugin.name}' installed successfully. You can now activate it."
-
end
-
-
# GET /admin/plugins/1/settings
-
def settings
-
# Try to load plugin instance to get schema and defaults
-
@plugin_instance = load_plugin_instance(@plugin)
-
-
if @plugin_instance&.has_settings?
-
# Get saved settings from PluginSetting model
-
saved_settings = @plugin_instance.get_all_settings
-
-
# Get default values from schema
-
default_settings = {}
-
@plugin_instance.settings_schema.each do |setting|
-
default_settings[setting[:key]] = setting[:default] if setting[:default]
-
end
-
-
# Merge defaults with saved settings (saved settings take precedence)
-
@plugin_settings = default_settings.merge(saved_settings)
-
@schema = @plugin_instance.settings_schema
-
else
-
# Fallback: get settings from plugin's settings attribute
-
saved_settings = @plugin.settings || {}
-
@plugin_settings = saved_settings
-
@schema = nil
-
end
-
end
-
-
# PATCH /admin/plugins/1/update_settings
-
def update_settings
-
@plugin = Plugin.find(params[:id])
-
new_settings = settings_params
-
-
# Try to get plugin instance for validation
-
@plugin_instance = load_plugin_instance(@plugin)
-
-
if @plugin_instance&.has_settings?
-
# Save settings using the plugin's settings system
-
begin
-
new_settings.each do |key, value|
-
@plugin_instance.set_setting(key, value)
-
end
-
-
redirect_to settings_admin_plugin_path(@plugin), notice: "Plugin settings updated successfully."
-
rescue => e
-
Rails.logger.error "Failed to update plugin settings: #{e.message}"
-
redirect_to settings_admin_plugin_path(@plugin), alert: "Failed to update plugin settings: #{e.message}"
-
end
-
else
-
# Fallback: save to plugin's settings attribute for plugins without schema
-
if @plugin.update(settings: new_settings)
-
redirect_to settings_admin_plugin_path(@plugin), notice: "Plugin settings updated successfully."
-
else
-
redirect_to settings_admin_plugin_path(@plugin), alert: "Failed to update plugin settings."
-
end
-
end
-
end
-
-
private
-
-
def set_plugin
-
@plugin = Plugin.find(params[:id])
-
end
-
-
def plugin_params
-
params.require(:plugin).permit(:name, :description, :author, :version, :active, :settings)
-
end
-
-
def load_plugin(plugin)
-
plugin_path = Rails.root.join('lib', 'plugins', plugin.name.underscore, "#{plugin.name.underscore}.rb")
-
-
if File.exist?(plugin_path)
-
require plugin_path
-
Rails.logger.info "Loaded plugin: #{plugin.name}"
-
end
-
end
-
-
def plugin_categories
-
[
-
'SEO & Marketing',
-
'Security',
-
'Performance',
-
'Social Media',
-
'Analytics',
-
'Content Enhancement',
-
'E-commerce',
-
'Forms & Contact',
-
'Media & Gallery',
-
'Development Tools'
-
]
-
end
-
-
# Mock plugin marketplace - In production, this would fetch from a real API
-
def fetch_available_plugins
-
[
-
{
-
slug: 'seo-optimizer',
-
name: 'SEO Optimizer Pro',
-
author: 'RailsPress Team',
-
version: '2.5.0',
-
description: 'Complete SEO solution with XML sitemaps, meta tag management, social media integration, and Google Analytics.',
-
long_description: 'SEO Optimizer Pro is the most comprehensive SEO plugin for RailsPress. It includes automatic XML sitemaps, advanced meta tag management, Open Graph and Twitter Card support, breadcrumbs, canonical URLs, and Google Analytics integration. Perfect for improving your search engine rankings.',
-
rating: 4.8,
-
downloads: 125000,
-
category: 'SEO & Marketing',
-
tags: ['seo', 'google', 'analytics', 'sitemap', 'meta tags'],
-
featured: true,
-
screenshots: ['screenshot1.png', 'screenshot2.png'],
-
requires: '1.0.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-09-15'
-
},
-
{
-
slug: 'contact-form-builder',
-
name: 'Contact Form Builder',
-
author: 'FormCraft',
-
version: '1.8.2',
-
description: 'Drag-and-drop form builder with email notifications, spam protection, and integrations.',
-
long_description: 'Build beautiful contact forms with our intuitive drag-and-drop interface. Includes reCAPTCHA integration, email notifications, file uploads, conditional logic, multi-page forms, and integrations with popular email marketing services.',
-
rating: 4.9,
-
downloads: 89000,
-
category: 'Forms & Contact',
-
tags: ['forms', 'contact', 'email', 'captcha'],
-
featured: true,
-
screenshots: [],
-
requires: '1.0.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-10-01'
-
},
-
{
-
slug: 'security-guardian',
-
name: 'Security Guardian',
-
author: 'SecureRails',
-
version: '3.1.0',
-
description: 'Advanced security features including firewall, malware scanning, and two-factor authentication.',
-
long_description: 'Protect your site with enterprise-grade security. Features include web application firewall, malware scanning, brute force protection, two-factor authentication, security headers, file integrity monitoring, and detailed security reports.',
-
rating: 4.7,
-
downloads: 67000,
-
category: 'Security',
-
tags: ['security', '2fa', 'firewall', 'malware'],
-
featured: false,
-
screenshots: [],
-
requires: '1.0.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-09-28'
-
},
-
{
-
slug: 'performance-booster',
-
name: 'Performance Booster',
-
author: 'SpeedUp Labs',
-
version: '2.0.3',
-
description: 'Comprehensive caching, minification, lazy loading, and CDN integration for maximum performance.',
-
long_description: 'Supercharge your site speed with advanced caching strategies, asset minification, image optimization, lazy loading, database query optimization, and CDN integration. Includes performance monitoring and detailed reports.',
-
rating: 4.6,
-
downloads: 54000,
-
category: 'Performance',
-
tags: ['cache', 'speed', 'optimization', 'cdn'],
-
featured: true,
-
screenshots: [],
-
requires: '1.0.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-10-05'
-
},
-
{
-
slug: 'social-share-buttons',
-
name: 'Social Share Buttons',
-
author: 'ShareKit',
-
version: '1.5.1',
-
description: 'Beautiful, customizable social media sharing buttons for all major platforms.',
-
long_description: 'Add stunning social sharing buttons to your posts and pages. Supports Facebook, Twitter, LinkedIn, Pinterest, Reddit, WhatsApp, and more. Fully customizable with multiple styles, floating sidebar option, and share count display.',
-
rating: 4.5,
-
downloads: 98000,
-
category: 'Social Media',
-
tags: ['social', 'sharing', 'facebook', 'twitter'],
-
featured: false,
-
screenshots: [],
-
requires: '1.0.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-08-20'
-
},
-
{
-
slug: 'analytics-dashboard',
-
name: 'Analytics Dashboard Pro',
-
author: 'DataViz Inc',
-
version: '4.2.0',
-
description: 'Real-time analytics dashboard with visitor tracking, heatmaps, and detailed reports.',
-
long_description: 'Get deep insights into your site traffic with real-time analytics. Track visitors, page views, bounce rates, conversion goals, heatmaps, user flow, geographic data, and more. Beautiful charts and exportable reports included.',
-
rating: 4.9,
-
downloads: 43000,
-
category: 'Analytics',
-
tags: ['analytics', 'tracking', 'reports', 'stats'],
-
featured: false,
-
screenshots: [],
-
requires: '1.2.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-10-10'
-
},
-
{
-
slug: 'markdown-editor',
-
name: 'Markdown Editor Plus',
-
author: 'EditorTech',
-
version: '1.3.0',
-
description: 'Enhanced markdown editor with live preview, syntax highlighting, and export options.',
-
long_description: 'Write content in markdown with our enhanced editor. Features include live preview, syntax highlighting, table support, footnotes, emoji picker, export to multiple formats, and seamless integration with the existing rich text editor.',
-
rating: 4.4,
-
downloads: 31000,
-
category: 'Content Enhancement',
-
tags: ['markdown', 'editor', 'writing'],
-
featured: false,
-
screenshots: [],
-
requires: '1.0.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-09-12'
-
},
-
{
-
slug: 'image-gallery',
-
name: 'Advanced Image Gallery',
-
author: 'GalleryPro',
-
version: '2.1.5',
-
description: 'Create stunning image galleries with lightbox, masonry layouts, and slideshow features.',
-
long_description: 'Build beautiful image galleries with multiple layout options including masonry, grid, carousel, and justified layouts. Features lightbox viewer, slideshow mode, lazy loading, touch gestures, captions, and social sharing.',
-
rating: 4.7,
-
downloads: 72000,
-
category: 'Media & Gallery',
-
tags: ['gallery', 'images', 'lightbox', 'slideshow'],
-
featured: false,
-
screenshots: [],
-
requires: '1.0.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-09-30'
-
},
-
{
-
slug: 'ecommerce-lite',
-
name: 'E-Commerce Lite',
-
author: 'ShopRails',
-
version: '3.0.0',
-
description: 'Lightweight e-commerce solution with product management, cart, and payment integration.',
-
long_description: 'Transform your site into an online store with our lightweight e-commerce plugin. Features include product catalog, shopping cart, checkout process, Stripe integration, inventory management, order tracking, and customer accounts.',
-
rating: 4.6,
-
downloads: 38000,
-
category: 'E-commerce',
-
tags: ['shop', 'ecommerce', 'products', 'payments'],
-
featured: true,
-
screenshots: [],
-
requires: '1.2.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-10-08'
-
},
-
{
-
slug: 'backup-manager',
-
name: 'Backup Manager',
-
author: 'BackupSafe',
-
version: '1.6.0',
-
description: 'Automated backups with cloud storage support and one-click restore functionality.',
-
long_description: 'Never lose your data with automated backups to cloud storage. Supports AWS S3, Google Cloud Storage, Dropbox, and more. Schedule automatic backups, one-click restore, incremental backups, and email notifications.',
-
rating: 4.8,
-
downloads: 56000,
-
category: 'Development Tools',
-
tags: ['backup', 'restore', 'cloud', 's3'],
-
featured: false,
-
screenshots: [],
-
requires: '1.0.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-09-25'
-
},
-
{
-
slug: 'multilingual',
-
name: 'Multilingual Content Manager',
-
author: 'TranslateCMS',
-
version: '2.3.1',
-
description: 'Complete multilingual solution with automatic translation and language switcher.',
-
long_description: 'Make your site multilingual with ease. Features include manual and automatic translation, language switcher widget, SEO for multiple languages, RTL support, translation management interface, and integration with Google Translate API.',
-
rating: 4.5,
-
downloads: 29000,
-
category: 'Content Enhancement',
-
tags: ['translation', 'multilingual', 'i18n', 'languages'],
-
featured: false,
-
screenshots: [],
-
requires: '1.3.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-10-02'
-
},
-
{
-
slug: 'email-marketing',
-
name: 'Email Marketing Suite',
-
author: 'MailRails',
-
version: '1.9.0',
-
description: 'Newsletter management, email campaigns, subscriber management, and analytics.',
-
long_description: 'Build your email list and send beautiful newsletters. Features include subscriber management, email campaign builder, drag-and-drop email designer, automation, segmentation, A/B testing, and detailed analytics.',
-
rating: 4.7,
-
downloads: 41000,
-
category: 'SEO & Marketing',
-
tags: ['email', 'newsletter', 'marketing', 'campaigns'],
-
featured: false,
-
screenshots: [],
-
requires: '1.1.0',
-
tested_up_to: '1.5.0',
-
last_updated: '2025-09-18'
-
}
-
]
-
end
-
-
# GET /admin/plugins/1/settings
-
-
# PATCH /admin/plugins/1/update_settings
-
def update_settings
-
new_settings = settings_params
-
-
# Try to get schema for validation
-
@plugin_instance = Railspress::PluginSystem.get_plugin(@plugin.name.underscore) rescue nil
-
if @plugin_instance && @plugin_instance.settings_schema
-
# Validate against schema
-
errors = @plugin_instance.settings_schema.validate(new_settings)
-
-
if errors.any?
-
flash[:alert] = "Validation errors: #{errors.values.flatten.join(', ')}"
-
redirect_to settings_admin_plugin_path(@plugin)
-
return
-
end
-
end
-
-
if @plugin.update(settings: new_settings)
-
redirect_to admin_plugins_path, notice: "Plugin settings updated successfully."
-
else
-
redirect_to settings_admin_plugin_path(@plugin), alert: "Failed to update plugin settings."
-
end
-
end
-
-
private
-
-
def set_plugin
-
@plugin = Plugin.find(params[:id])
-
end
-
-
def plugin_params
-
params.require(:plugin).permit(:name, :description, :author, :version, :active)
-
end
-
-
def settings_params
-
# Handle both nested and flat settings parameters
-
if params[:plugin] && params[:plugin][:settings]
-
params.require(:plugin).permit(:settings).to_h.dig(:settings) || {}
-
elsif params[:settings]
-
params.permit(settings: {}).to_h[:settings] || {}
-
else
-
{}
-
end
-
end
-
-
def load_plugin(plugin)
-
plugin_path = Rails.root.join('lib', 'plugins', plugin.name.underscore, "#{plugin.name.underscore}.rb")
-
-
if File.exist?(plugin_path)
-
begin
-
load plugin_path
-
Rails.logger.info "Successfully loaded plugin: #{plugin.name}"
-
true
-
rescue => e
-
Rails.logger.error "Failed to load plugin #{plugin.name}: #{e.message}"
-
false
-
end
-
else
-
Rails.logger.warn "Plugin file not found: #{plugin_path}"
-
true # Don't fail if file doesn't exist yet
-
end
-
end
-
-
def discover_and_register_plugins
-
plugins_dir = Rails.root.join('lib', 'plugins')
-
return unless Dir.exist?(plugins_dir)
-
-
# Scan for plugin directories
-
Dir.glob(File.join(plugins_dir, '*')).each do |plugin_dir|
-
next unless File.directory?(plugin_dir)
-
-
plugin_name = File.basename(plugin_dir)
-
plugin_file = File.join(plugin_dir, "#{plugin_name}.rb")
-
-
next unless File.exist?(plugin_file)
-
-
# Check if plugin is already registered
-
next if Plugin.exists?(name: plugin_name.humanize)
-
-
begin
-
# Load the plugin file to get metadata
-
load plugin_file
-
-
# Try to get plugin class and metadata
-
plugin_class_name = plugin_name.classify
-
plugin_class = plugin_class_name.constantize rescue nil
-
-
if plugin_class && plugin_class.ancestors.include?(Railspress::PluginBase)
-
# Create plugin instance to get metadata
-
plugin_instance = plugin_class.new
-
-
# Get metadata from instance
-
plugin_name_str = plugin_instance.name
-
plugin_version_str = plugin_instance.version
-
plugin_description_str = plugin_instance.description
-
plugin_author_str = plugin_instance.author || 'Unknown'
-
-
# Register in database
-
Plugin.create!(
-
name: plugin_name_str,
-
description: plugin_description_str,
-
author: plugin_author_str,
-
version: plugin_version_str,
-
active: false
-
)
-
-
Rails.logger.info "Auto-registered plugin: #{plugin_name_str}"
-
else
-
# If plugin class doesn't inherit from PluginBase, try to register with basic info
-
Plugin.create!(
-
name: plugin_name.humanize,
-
description: "Plugin: #{plugin_name.humanize}",
-
author: 'Unknown',
-
version: '1.0.0',
-
active: false
-
)
-
-
Rails.logger.info "Auto-registered basic plugin: #{plugin_name.humanize}"
-
end
-
rescue => e
-
Rails.logger.error "Failed to auto-register plugin #{plugin_name}: #{e.message}"
-
end
-
end
-
end
-
-
def load_plugin_instance(plugin)
-
# Dynamic plugin discovery - find the plugin directory that matches this plugin
-
plugins_dir = Rails.root.join('lib', 'plugins')
-
return nil unless Dir.exist?(plugins_dir)
-
-
Dir.glob(File.join(plugins_dir, '*')).each do |plugin_dir|
-
next unless File.directory?(plugin_dir)
-
-
candidate_name = File.basename(plugin_dir)
-
plugin_file = File.join(plugin_dir, "#{candidate_name}.rb")
-
-
next unless File.exist?(plugin_file)
-
-
begin
-
# Load the plugin file
-
load plugin_file
-
plugin_class_name = candidate_name.classify
-
plugin_class = plugin_class_name.constantize rescue nil
-
-
if plugin_class && plugin_class.ancestors.include?(Railspress::PluginBase)
-
# Create a temporary instance to check the name
-
temp_instance = plugin_class.new
-
if temp_instance.name == plugin.name
-
return temp_instance
-
end
-
end
-
rescue => e
-
# Continue to next plugin if this one fails
-
next
-
end
-
end
-
-
nil
-
end
-
end
-
class Admin::PostsController < Admin::BaseController
-
before_action :set_post, only: %i[ show edit update destroy publish unpublish write restore versions restore_version ]
-
layout :choose_layout
-
-
# GET /admin/posts or /admin/posts.json
-
def index
-
@posts = Post.kept.includes(:user, :terms).order(created_at: :desc)
-
-
# Filter by status if specified
-
if params[:status].present? && Post.statuses.keys.include?(params[:status])
-
@posts = @posts.where(status: params[:status])
-
end
-
-
# Show trashed if explicitly requested
-
if params[:show_trash] == 'true'
-
@posts = Post.trashed.includes(:user, :terms).order(deleted_at: :desc)
-
end
-
-
respond_to do |format|
-
format.html do
-
@posts_data = posts_json
-
@stats = {
-
total: Post.kept.count,
-
published: Post.published.count,
-
draft: Post.where(status: 'draft').count,
-
trash: Post.trashed.count
-
}
-
@bulk_actions = [
-
{ value: 'trash', label: 'Move to Trash' },
-
{ value: 'untrash', label: 'Restore' },
-
{ value: 'delete', label: 'Delete Permanently' }
-
]
-
@status_options = [
-
{ value: 'published', label: 'Published' },
-
{ value: 'draft', label: 'Draft' },
-
{ value: 'pending', label: 'Pending' }
-
]
-
@columns = [
-
{
-
title: "",
-
formatter: "rowSelection",
-
titleFormatter: "rowSelection",
-
width: 40,
-
headerSort: false
-
},
-
{
-
title: "Title",
-
field: "title",
-
width: 300,
-
formatter: "html"
-
},
-
{
-
title: "Author",
-
field: "author_name",
-
width: 150
-
},
-
{
-
title: "Status",
-
field: "status",
-
width: 100,
-
formatter: "html"
-
},
-
{
-
title: "Categories",
-
field: "categories",
-
width: 150,
-
formatter: "html"
-
},
-
{
-
title: "Tags",
-
field: "tags",
-
width: 150,
-
formatter: "html"
-
},
-
{
-
title: "Date",
-
field: "created_at",
-
width: 150,
-
formatter: "datetime",
-
formatterParams: {
-
inputFormat: "YYYY-MM-DDTHH:mm:ss.SSSZ",
-
outputFormat: "DD/MM/YYYY HH:mm"
-
}
-
},
-
{
-
title: "Actions",
-
field: "actions",
-
width: 120,
-
headerSort: false,
-
formatter: "html"
-
}
-
]
-
end
-
format.json { render json: posts_json }
-
end
-
end
-
-
# GET /admin/posts/1 or /admin/posts/1.json
-
def show
-
end
-
-
# GET /admin/posts/new
-
def new
-
@post = current_user.posts.build(status: :draft)
-
@categories = Term.for_taxonomy('category').ordered
-
@tags = Term.for_taxonomy('post_tag').ordered
-
end
-
-
# GET /admin/posts/write (collection)
-
def write_new
-
@post = current_user.posts.build(status: :draft)
-
@categories = Term.for_taxonomy('category').ordered
-
@tags = Term.for_taxonomy('post_tag').ordered
-
render :write, layout: 'write_fullscreen'
-
end
-
-
# GET /admin/posts/:id/write (member)
-
def write
-
@categories = Term.for_taxonomy('category').ordered
-
@tags = Term.for_taxonomy('post_tag').ordered
-
render layout: 'write_fullscreen'
-
end
-
-
# GET /admin/posts/1/edit
-
def edit
-
@categories = Term.for_taxonomy('category').ordered
-
@tags = Term.for_taxonomy('post_tag').ordered
-
end
-
-
# POST /admin/posts or /admin/posts.json
-
def create
-
@post = current_user.posts.build(post_params)
-
-
respond_to do |format|
-
if @post.save
-
if params[:autosave] == 'true'
-
# Autosave response - redirect to edit page for continued editing
-
format.json { render json: { status: 'success', id: @post.id, edit_url: admin_post_path(@post) } }
-
else
-
format.html { redirect_to [:admin, @post], notice: "Post was successfully created." }
-
format.json { render :show, status: :created, location: @post }
-
end
-
else
-
@categories = Term.for_taxonomy('category').ordered
-
@tags = Term.for_taxonomy('post_tag').ordered
-
if params[:autosave] == 'true'
-
format.json { render json: { status: 'error', errors: @post.errors }, status: :unprocessable_entity }
-
else
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @post.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/posts/1 or /admin/posts/1.json
-
def update
-
respond_to do |format|
-
if @post.update(post_params)
-
if params[:autosave] == 'true'
-
# Autosave response - just return success
-
format.json { render json: { status: 'success', updated_at: @post.updated_at } }
-
else
-
format.html { redirect_to [:admin, @post], notice: "Post was successfully updated.", status: :see_other }
-
format.json { render :show, status: :ok, location: @post }
-
end
-
else
-
@categories = Term.for_taxonomy('category').ordered
-
@tags = Term.for_taxonomy('post_tag').ordered
-
if params[:autosave] == 'true'
-
format.json { render json: { status: 'error', errors: @post.errors }, status: :unprocessable_entity }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @post.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
end
-
-
# DELETE /admin/posts/1 or /admin/posts/1.json
-
def destroy
-
if @post.trashed?
-
@post.destroy_permanently! # Permanent delete
-
notice = "Post was permanently deleted."
-
else
-
@post.trash!(current_user) # Soft delete
-
notice = "Post was moved to trash."
-
end
-
-
respond_to do |format|
-
format.html { redirect_to admin_posts_path, notice: notice, status: :see_other }
-
format.json { head :no_content }
-
end
-
end
-
-
# PATCH /admin/posts/1/publish
-
def publish
-
@post.update(status: :published, published_at: Time.current)
-
redirect_to [:admin, @post], notice: "Post was successfully published."
-
end
-
-
# PATCH /admin/posts/1/unpublish
-
def unpublish
-
@post.update(status: :draft)
-
redirect_to [:admin, @post], notice: "Post was unpublished."
-
end
-
-
# PATCH /admin/posts/1/restore
-
def restore
-
@post.untrash!
-
redirect_to admin_posts_path, notice: "Post was restored from trash."
-
end
-
-
# POST /admin/posts/bulk_action
-
def bulk_action
-
action_type = params[:action_type]
-
post_ids = params[:ids] || []
-
-
posts = Post.where(id: post_ids)
-
-
case action_type
-
when 'publish'
-
posts.update_all(status: :published, published_at: Time.current)
-
message = "#{posts.count} posts published"
-
when 'unpublish'
-
posts.update_all(status: :draft)
-
message = "#{posts.count} posts unpublished"
-
when 'trash'
-
posts.find_each { |post| post.trash!(current_user) }
-
message = "#{posts.count} posts moved to trash"
-
when 'untrash'
-
posts.find_each(&:untrash!)
-
message = "#{posts.count} posts restored from trash"
-
when 'delete'
-
posts.find_each(&:destroy_permanently!)
-
message = "#{posts.count} posts permanently deleted"
-
else
-
message = "Invalid action"
-
end
-
-
respond_to do |format|
-
format.json { render json: { success: true, message: message } }
-
end
-
end
-
-
private
-
# Use callbacks to share common setup or constraints between actions.
-
def set_post
-
@post = Post.friendly.find(params[:id])
-
end
-
-
# Only allow a list of trusted parameters through.
-
def post_params
-
params.require(:post).permit(
-
:title, :slug, :content, :excerpt, :status, :published_at,
-
:featured_image, :meta_description, :meta_keywords,
-
:featured_image_file, :password, :password_hint,
-
category_ids: [], tag_ids: []
-
)
-
end
-
-
def posts_json
-
@posts.map do |post|
-
categories = post.terms_for_taxonomy('category').pluck(:name)
-
tags = post.terms_for_taxonomy('post_tag').pluck(:name)
-
-
{
-
id: post.id,
-
title: "<a href=\"#{edit_admin_post_path(post)}\" class=\"text-indigo-600 hover:text-indigo-900 font-medium\">#{post.title}</a>",
-
slug: post.slug,
-
status: format_status_badge(post.status),
-
status_raw: post.status, # Raw status for CSS classes
-
author_name: post.user&.name || 'Unknown',
-
categories: format_categories(categories),
-
tags: format_tags(tags),
-
comments_count: post.comments.where(status: 'approved').count,
-
created_at: post.created_at.iso8601,
-
published_at: post.published_at&.iso8601,
-
actions: format_actions(post),
-
edit_url: edit_admin_post_path(post),
-
show_url: admin_post_path(post),
-
delete_url: admin_post_path(post)
-
}
-
end
-
end
-
-
private
-
-
def format_status_badge(status)
-
status_map = {
-
'published' => { class: 'bg-green-100 text-green-800', label: 'Published' },
-
'draft' => { class: 'bg-yellow-100 text-yellow-800', label: 'Draft' },
-
'pending' => { class: 'bg-blue-100 text-blue-800', label: 'Pending' },
-
'trash' => { class: 'bg-red-100 text-red-800', label: 'Trash' }
-
}
-
-
status_info = status_map[status] || { class: 'bg-gray-100 text-gray-800', label: status }
-
"<span class=\"px-2 py-1 text-xs font-medium rounded-full #{status_info[:class]}\">#{status_info[:label]}</span>"
-
end
-
-
def format_categories(categories)
-
if categories.empty?
-
'<span class="text-gray-400">Uncategorized</span>'
-
else
-
categories.map { |cat| "<span class=\"px-2 py-1 text-xs bg-gray-100 text-gray-800 rounded mr-1\">#{cat}</span>" }.join('')
-
end
-
end
-
-
def format_tags(tags)
-
if tags.empty?
-
''
-
else
-
tags.map { |tag| "<span class=\"px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded mr-1\">#{tag}</span>" }.join('')
-
end
-
end
-
-
def format_actions(post)
-
actions = ''
-
actions += "<a href=\"#{edit_admin_post_path(post)}\" class=\"text-indigo-600 hover:text-indigo-900 mr-2\" title=\"Edit\">✏️</a>"
-
actions += "<a href=\"#{admin_post_path(post)}\" class=\"text-blue-600 hover:text-blue-900 mr-2\" title=\"View\">👁️</a>"
-
actions += "<a href=\"#{admin_post_path(post)}\" class=\"text-red-600 hover:text-red-900\" title=\"Delete\" data-confirm=\"Are you sure?\">🗑️</a>"
-
actions
-
end
-
-
def choose_layout
-
action_name == 'write' || action_name == 'write_new' ? 'write_fullscreen' : 'admin'
-
end
-
-
# Version-related actions
-
def versions
-
@versions = @post.versions.includes(:user).order(created_at: :desc)
-
-
respond_to do |format|
-
format.html
-
format.json { render json: @versions.map { |v| version_json(v) } }
-
end
-
end
-
-
def restore_version
-
version_id = params[:version_id]
-
-
if @post.restore_to_version(version_id)
-
redirect_to edit_admin_post_path(@post), notice: 'Version restored successfully!'
-
else
-
redirect_to versions_admin_post_path(@post), alert: 'Failed to restore version.'
-
end
-
end
-
-
private
-
-
def version_json(version)
-
{
-
id: version.id,
-
created_at: version.created_at,
-
user: version.whodunnit ? User.find_by(id: version.whodunnit)&.name : 'System',
-
summary: @post.version_summary(version),
-
changes: version.changeset.keys,
-
event: version.event
-
}
-
end
-
end
-
class Admin::ProfileController < Admin::BaseController
-
before_action :set_user
-
-
# GET /admin/profile
-
def show
-
redirect_to edit_admin_profile_path
-
end
-
-
# GET /admin/profile/edit
-
def edit
-
@available_editors = available_editors
-
end
-
-
# PATCH /admin/profile
-
def update
-
user_params_filtered = user_params
-
-
# Remove password params if not provided
-
if params[:user][:password].blank?
-
user_params_filtered = user_params_filtered.except(:password, :password_confirmation)
-
end
-
-
# Handle avatar upload
-
if params[:user][:avatar].present?
-
@user.avatar.attach(params[:user][:avatar])
-
end
-
-
if @user.update(user_params_filtered)
-
redirect_to edit_admin_profile_path, notice: 'Profile updated successfully.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/profile/avatar
-
def remove_avatar
-
@user.avatar.purge if @user.avatar.attached?
-
redirect_to edit_admin_profile_path, notice: 'Avatar removed successfully.'
-
end
-
-
private
-
-
def set_user
-
@user = current_user
-
end
-
-
def user_params
-
params.require(:user).permit(:email, :name, :password, :password_confirmation, :avatar, :bio, :website, :twitter, :github, :linkedin, :phone, :location, :avatar_url, :editor_preference)
-
end
-
-
def available_editors
-
[
-
['BlockNote (Modern Block Editor)', 'blocknote'],
-
['Editor.js (Rich Block Editor)', 'editorjs'],
-
['Trix (Rich Text Editor)', 'trix'],
-
['Simple Textarea', 'simple']
-
]
-
end
-
end
-
class Admin::RedirectsController < Admin::BaseController
-
before_action :set_redirect, only: [:edit, :update, :destroy, :toggle]
-
-
# GET /admin/redirects
-
def index
-
@redirects = Redirect.includes(:versions)
-
.order(created_at: :desc)
-
.page(params[:page])
-
.per(20)
-
-
# Filter by status
-
@redirects = @redirects.active if params[:status] == 'active'
-
@redirects = @redirects.inactive if params[:status] == 'inactive'
-
-
# Filter by type
-
@redirects = @redirects.by_type(params[:type]) if params[:type].present?
-
-
# Search
-
if params[:search].present?
-
search_term = "%#{params[:search]}%"
-
@redirects = @redirects.where('from_path LIKE ? OR to_path LIKE ?', search_term, search_term)
-
end
-
-
# Stats
-
@stats = {
-
total: Redirect.count,
-
active: Redirect.active.count,
-
inactive: Redirect.inactive.count,
-
total_hits: Redirect.sum(:hits_count)
-
}
-
end
-
-
# GET /admin/redirects/new
-
def new
-
@redirect = Redirect.new
-
end
-
-
# GET /admin/redirects/:id/edit
-
def edit
-
end
-
-
# POST /admin/redirects
-
def create
-
@redirect = Redirect.new(redirect_params)
-
-
if @redirect.save
-
redirect_to admin_redirects_path, notice: 'Redirect created successfully.'
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /admin/redirects/:id
-
def update
-
if @redirect.update(redirect_params)
-
redirect_to admin_redirects_path, notice: 'Redirect updated successfully.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/redirects/:id
-
def destroy
-
@redirect.destroy
-
redirect_to admin_redirects_path, notice: 'Redirect deleted successfully.'
-
end
-
-
# PATCH /admin/redirects/:id/toggle
-
def toggle
-
@redirect.update(active: !@redirect.active)
-
redirect_to admin_redirects_path, notice: "Redirect #{@redirect.active? ? 'activated' : 'deactivated'}."
-
end
-
-
# POST /admin/redirects/bulk_action
-
def bulk_action
-
redirect_ids = params[:redirect_ids] || []
-
action = params[:bulk_action]
-
-
case action
-
when 'activate'
-
Redirect.where(id: redirect_ids).update_all(active: true)
-
message = "#{redirect_ids.count} redirects activated."
-
when 'deactivate'
-
Redirect.where(id: redirect_ids).update_all(active: false)
-
message = "#{redirect_ids.count} redirects deactivated."
-
when 'delete'
-
Redirect.where(id: redirect_ids).destroy_all
-
message = "#{redirect_ids.count} redirects deleted."
-
else
-
message = "Invalid action."
-
end
-
-
redirect_to admin_redirects_path, notice: message
-
end
-
-
# GET /admin/redirects/import
-
def import
-
end
-
-
# POST /admin/redirects/do_import
-
def do_import
-
unless params[:file].present?
-
redirect_to import_admin_redirects_path, alert: 'Please select a file to import.'
-
return
-
end
-
-
file = params[:file]
-
-
begin
-
require 'csv'
-
csv_data = CSV.parse(file.read, headers: true)
-
-
data = csv_data.map do |row|
-
{
-
from_path: row['From Path'] || row['from_path'],
-
to_path: row['To Path'] || row['to_path'],
-
redirect_type: row['Type'] || row['type'] || 'permanent',
-
notes: row['Notes'] || row['notes']
-
}
-
end
-
-
result = Redirect.import_redirects(data)
-
-
if result[:errors].empty?
-
redirect_to admin_redirects_path, notice: "Successfully imported #{result[:imported]} redirects."
-
else
-
flash[:alert] = "Imported #{result[:imported]} redirects with #{result[:errors].count} errors."
-
redirect_to admin_redirects_path
-
end
-
rescue => e
-
redirect_to import_admin_redirects_path, alert: "Import failed: #{e.message}"
-
end
-
end
-
-
# GET /admin/redirects/export
-
def export
-
csv_data = Redirect.to_csv
-
-
send_data csv_data,
-
filename: "redirects-#{Date.today}.csv",
-
type: 'text/csv',
-
disposition: 'attachment'
-
end
-
-
private
-
-
def set_redirect
-
@redirect = Redirect.find(params[:id])
-
end
-
-
def redirect_params
-
params.require(:redirect).permit(
-
:from_path,
-
:to_path,
-
:redirect_type,
-
:status_code,
-
:active,
-
:notes
-
)
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::SecurityController < Admin::BaseController
-
before_action :set_user
-
-
# GET /admin/security
-
def show
-
@login_history = load_login_history
-
@active_sessions = load_active_sessions
-
end
-
-
# PATCH /admin/security/update_password
-
def update_password
-
unless @user.valid_password?(params[:current_password])
-
redirect_to admin_security_path, alert: 'Current password is incorrect.'
-
return
-
end
-
-
if params[:new_password] != params[:confirm_password]
-
redirect_to admin_security_path, alert: 'New passwords do not match.'
-
return
-
end
-
-
if @user.update(password: params[:new_password], password_confirmation: params[:confirm_password])
-
# Sign in again after password change
-
sign_in(@user, bypass: true)
-
redirect_to admin_security_path, notice: 'Password updated successfully.'
-
else
-
redirect_to admin_security_path, alert: 'Failed to update password. Must be at least 6 characters.'
-
end
-
end
-
-
# POST /admin/security/enable_2fa
-
def enable_2fa
-
# Placeholder for 2FA implementation
-
redirect_to admin_security_path, notice: 'Two-factor authentication feature coming soon.'
-
end
-
-
# DELETE /admin/security/disable_2fa
-
def disable_2fa
-
# Placeholder for 2FA implementation
-
redirect_to admin_security_path, notice: 'Two-factor authentication disabled.'
-
end
-
-
# POST /admin/security/regenerate_api_token
-
def regenerate_api_token
-
@user.regenerate_api_token!
-
redirect_to admin_security_path, notice: 'API token regenerated successfully.'
-
end
-
-
# DELETE /admin/security/revoke_sessions
-
def revoke_sessions
-
# This would revoke all other sessions except current
-
# Implementation depends on session management strategy
-
redirect_to admin_security_path, notice: 'All other sessions have been revoked.'
-
end
-
-
private
-
-
def set_user
-
@user = current_user
-
end
-
-
def load_login_history
-
# Placeholder - would come from a login_history table
-
[]
-
end
-
-
def load_active_sessions
-
# Placeholder - would come from sessions tracking
-
[]
-
end
-
end
-
class Admin::SessionsController < Devise::SessionsController
-
layout 'admin_login'
-
helper AppearanceHelper
-
-
-
# Temporarily disable CSRF protection for admin login to fix the issue
-
skip_before_action :verify_authenticity_token, only: [:create]
-
-
-
# Override after_sign_in to check admin access
-
def after_sign_in_path_for(resource)
-
# Check if user has admin access
-
unless resource.author? || resource.editor? || resource.administrator?
-
sign_out(resource)
-
flash[:alert] = 'You do not have permission to access the admin area.'
-
new_admin_user_session_path
-
else
-
admin_root_path
-
end
-
end
-
-
# Override to redirect to admin login after logout
-
def after_sign_out_path_for(resource_or_scope)
-
new_admin_user_session
-
end
-
end
-
class Admin::Settings::StorageController < ApplicationController
-
def index
-
end
-
-
def new
-
end
-
-
def create
-
end
-
-
def edit
-
end
-
-
def update
-
end
-
-
def destroy
-
end
-
end
-
class Admin::Settings::UploadSecurityController < Admin::BaseController
-
before_action :set_upload_security
-
-
# GET /admin/settings/upload_security
-
def show
-
end
-
-
# PATCH /admin/settings/upload_security
-
def update
-
if @upload_security.update(upload_security_params)
-
redirect_to admin_settings_upload_security_path, notice: "Upload security settings updated successfully."
-
else
-
render :show, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def set_upload_security
-
@upload_security = UploadSecurity.current
-
end
-
-
def upload_security_params
-
params.require(:upload_security).permit(
-
:max_file_size_human,
-
:allowed_extensions_list,
-
:blocked_extensions_list,
-
:allowed_mime_types_list,
-
:blocked_mime_types_list,
-
:scan_for_viruses,
-
:quarantine_suspicious,
-
:auto_approve_trusted
-
)
-
end
-
end
-
-
class Admin::SettingsController < Admin::BaseController
-
before_action :ensure_admin
-
-
# GET /admin/settings
-
def index
-
redirect_to admin_general_settings_path
-
end
-
-
# GET /admin/settings/general
-
def general
-
load_general_settings
-
end
-
-
# GET /admin/settings/writing
-
def writing
-
load_writing_settings
-
end
-
-
# GET /admin/settings/reading
-
def reading
-
load_reading_settings
-
end
-
-
# GET /admin/settings/discussion
-
def discussion
-
load_discussion_settings
-
end
-
-
# GET /admin/settings/media
-
def media
-
load_media_settings
-
end
-
-
# GET /admin/settings/permalinks
-
def permalinks
-
load_permalink_settings
-
end
-
-
# GET /admin/settings/privacy
-
def privacy
-
load_privacy_settings
-
end
-
-
# GET /admin/settings/email
-
def email
-
load_email_settings
-
end
-
-
# GET /admin/settings/post_by_email
-
def post_by_email
-
# Settings are loaded dynamically in the view
-
end
-
-
# GET /admin/settings/white_label
-
def white_label
-
load_white_label_settings
-
end
-
-
# GET /admin/settings/appearance
-
def appearance
-
load_appearance_settings
-
end
-
-
# GET /admin/settings/storage
-
def storage
-
load_storage_settings
-
end
-
-
# PATCH /admin/settings/update_general
-
def update_general
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
redirect_to admin_general_settings_path, notice: 'General settings updated successfully.'
-
end
-
-
# PATCH /admin/settings/update_writing
-
def update_writing
-
# Update site settings
-
if params[:settings]
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
end
-
-
# Update user's editor preference
-
if params[:user] && params[:user][:editor_preference]
-
current_user.update(editor_preference: params[:user][:editor_preference])
-
end
-
-
redirect_to admin_writing_settings_path, notice: 'Writing settings updated successfully.'
-
end
-
-
# PATCH /admin/settings/update_reading
-
def update_reading
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
redirect_to admin_reading_settings_path, notice: 'Reading settings updated successfully.'
-
end
-
-
# PATCH /admin/settings/update_discussion
-
def update_discussion
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
redirect_to admin_discussion_settings_path, notice: 'Discussion settings updated successfully.'
-
end
-
-
# PATCH /admin/settings/update_media
-
def update_media
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
redirect_to admin_media_settings_path, notice: 'Media settings updated successfully.'
-
end
-
-
# PATCH /admin/settings/update_permalinks
-
def update_permalinks
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
redirect_to admin_permalinks_settings_path, notice: 'Permalink settings updated successfully.'
-
end
-
-
# PATCH /admin/settings/update_privacy
-
def update_privacy
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
redirect_to admin_privacy_settings_path, notice: 'Privacy settings updated successfully.'
-
end
-
-
# PATCH /admin/settings/update_email
-
def update_email
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
-
# Apply email configuration
-
configure_action_mailer
-
-
redirect_to admin_email_settings_path, notice: 'Email settings updated successfully.'
-
end
-
-
# POST /admin/settings/test_email
-
def test_email
-
provider = SiteSetting.get('email_provider', 'smtp')
-
-
begin
-
TestMailer.test_email(params[:test_email_address]).deliver_now
-
-
render json: {
-
success: true,
-
message: "Test email sent successfully via #{provider.upcase}!"
-
}
-
rescue => e
-
render json: {
-
success: false,
-
message: "Failed to send test email: #{e.message}"
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /admin/settings/update_post_by_email
-
def update_post_by_email
-
# Save all post by email settings
-
[
-
'post_by_email_enabled',
-
'imap_server',
-
'imap_port',
-
'imap_email',
-
'imap_password',
-
'imap_ssl',
-
'imap_folder',
-
'post_by_email_default_category',
-
'post_by_email_default_author',
-
'post_by_email_mark_as_read',
-
'post_by_email_delete_after_import'
-
].each do |key|
-
value = params[key]
-
if value.present?
-
SiteSetting.set(key, value, setting_type_for(key))
-
elsif key == 'post_by_email_enabled' || key == 'post_by_email_mark_as_read' || key == 'post_by_email_delete_after_import'
-
# Handle unchecked checkboxes
-
SiteSetting.set(key, false, 'boolean')
-
end
-
end
-
-
render json: { success: true, message: 'Post by Email settings saved successfully!' }
-
rescue => e
-
Rails.logger.error "Error saving post by email settings: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
render json: { success: false, error: e.message }, status: :unprocessable_entity
-
end
-
-
# POST /admin/settings/test_post_by_email
-
def test_post_by_email
-
begin
-
result = PostByEmailService.check_mail
-
-
render json: {
-
success: true,
-
message: "#{result[:new_posts]} new post(s) created, #{result[:checked]} email(s) checked"
-
}
-
rescue => e
-
Rails.logger.error "Error testing post by email: #{e.message}"
-
render json: {
-
success: false,
-
error: "Connection failed: #{e.message}"
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /admin/settings/update_white_label
-
def update_white_label
-
# Handle logo upload if present
-
if params[:settings] && params[:settings][:admin_logo].present?
-
# Store logo using ActiveStorage or similar
-
logo_file = params[:settings][:admin_logo]
-
SiteSetting.set('admin_logo_url', store_logo(logo_file), :string)
-
params[:settings].delete(:admin_logo)
-
end
-
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
-
redirect_to admin_settings_white_label_path, notice: 'White label settings updated successfully.'
-
end
-
-
# PATCH /admin/settings/update_appearance
-
def update_appearance
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
-
redirect_to admin_settings_appearance_path, notice: 'Appearance settings updated successfully.'
-
end
-
-
# PATCH /admin/settings/update_storage
-
def update_storage
-
# Update storage settings
-
if params[:settings]
-
params[:settings].each do |key, value|
-
SiteSetting.set(key, value, setting_type_for(key))
-
end
-
end
-
-
# Update tenant storage configuration if we have a current tenant
-
if defined?(ActsAsTenant) && ActsAsTenant.current_tenant
-
tenant = ActsAsTenant.current_tenant
-
tenant.update!(
-
storage_type: params[:storage_type] || 'local',
-
storage_bucket: params[:storage_bucket],
-
storage_region: params[:storage_region],
-
storage_access_key: params[:storage_access_key],
-
storage_secret_key: params[:storage_secret_key],
-
storage_endpoint: params[:storage_endpoint],
-
storage_path: params[:storage_path]
-
)
-
end
-
-
# Apply storage configuration
-
begin
-
storage_config = StorageConfigurationService.new
-
storage_config.configure_active_storage
-
storage_config.update_storage_config
-
rescue => e
-
Rails.logger.error "Failed to apply storage configuration: #{e.message}"
-
redirect_to admin_storage_settings_path, alert: 'Storage settings updated but configuration failed to apply. Please check the logs.'
-
return
-
end
-
-
redirect_to admin_storage_settings_path, notice: 'Storage settings updated successfully.'
-
end
-
-
private
-
-
def load_general_settings
-
@settings = {
-
site_title: SiteSetting.get('site_title', 'RailsPress'),
-
site_tagline: SiteSetting.get('site_tagline', 'A Ruby on Rails CMS'),
-
site_url: SiteSetting.get('site_url', 'http://localhost:3000'),
-
admin_email: SiteSetting.get('admin_email', 'admin@railspress.com'),
-
timezone: SiteSetting.get('timezone', 'UTC'),
-
date_format: SiteSetting.get('date_format', '%B %d, %Y'),
-
time_format: SiteSetting.get('time_format', '%H:%M'),
-
language: SiteSetting.get('language', 'en')
-
}
-
end
-
-
def load_writing_settings
-
@settings = {
-
default_post_status: SiteSetting.get('default_post_status', 'draft'),
-
default_post_category: SiteSetting.get('default_post_category', ''),
-
default_post_format: SiteSetting.get('default_post_format', 'standard'),
-
enable_auto_save: SiteSetting.get('enable_auto_save', true),
-
auto_save_interval: SiteSetting.get('auto_save_interval', 60),
-
enable_revisions: SiteSetting.get('enable_revisions', true),
-
max_revisions: SiteSetting.get('max_revisions', 10),
-
rich_editor_type: SiteSetting.get('rich_editor_type', 'trix')
-
}
-
end
-
-
def load_reading_settings
-
@settings = {
-
posts_per_page: SiteSetting.get('posts_per_page', 10),
-
posts_per_rss: SiteSetting.get('posts_per_rss', 10),
-
homepage_display: SiteSetting.get('homepage_display', 'posts'),
-
homepage_page_id: SiteSetting.get('homepage_page_id', ''),
-
blog_page_id: SiteSetting.get('blog_page_id', ''),
-
show_on_front: SiteSetting.get('show_on_front', 'posts'),
-
excerpt_length: SiteSetting.get('excerpt_length', 200)
-
}
-
end
-
-
def load_media_settings
-
@settings = {
-
image_max_width: SiteSetting.get('image_max_width', 2048),
-
image_max_height: SiteSetting.get('image_max_height', 2048),
-
thumbnail_width: SiteSetting.get('thumbnail_width', 150),
-
thumbnail_height: SiteSetting.get('thumbnail_height', 150),
-
medium_width: SiteSetting.get('medium_width', 300),
-
medium_height: SiteSetting.get('medium_height', 300),
-
large_width: SiteSetting.get('large_width', 1024),
-
large_height: SiteSetting.get('large_height', 1024),
-
auto_optimize_images: SiteSetting.get('auto_optimize_images', false),
-
# System-wide compression level settings
-
image_compression_level: SiteSetting.get('image_compression_level', 'lossy'),
-
image_quality: SiteSetting.get('image_quality', 85),
-
image_compression_level_value: SiteSetting.get('image_compression_level_value', 6),
-
strip_image_metadata: SiteSetting.get('strip_image_metadata', true),
-
enable_webp_variants: SiteSetting.get('enable_webp_variants', true),
-
enable_avif_variants: SiteSetting.get('enable_avif_variants', true),
-
allowed_file_types: SiteSetting.get('allowed_file_types', 'jpg,jpeg,png,gif,pdf,doc,docx'),
-
max_upload_size: SiteSetting.get('max_upload_size', 10)
-
}
-
end
-
-
def load_permalink_settings
-
@settings = {
-
permalink_structure: SiteSetting.get('permalink_structure', '/blog/:slug'),
-
category_base: SiteSetting.get('category_base', 'category'),
-
tag_base: SiteSetting.get('tag_base', 'tag'),
-
use_trailing_slash: SiteSetting.get('use_trailing_slash', false),
-
auto_redirect_old_urls: SiteSetting.get('auto_redirect_old_urls', true)
-
}
-
end
-
-
def load_discussion_settings
-
@settings = {
-
comments_enabled: SiteSetting.get('comments_enabled', true),
-
comments_moderation: SiteSetting.get('comments_moderation', true),
-
comment_registration_required: SiteSetting.get('comment_registration_required', false),
-
close_comments_after_days: SiteSetting.get('close_comments_after_days', 0),
-
show_avatars: SiteSetting.get('show_avatars', true),
-
akismet_api_key: SiteSetting.get('akismet_api_key', ''),
-
akismet_enabled: SiteSetting.get('akismet_enabled', false)
-
}
-
end
-
-
def load_privacy_settings
-
@settings = {
-
gdpr_compliance_enabled: SiteSetting.get('gdpr_compliance_enabled', false),
-
cookie_consent_required: SiteSetting.get('cookie_consent_required', false),
-
privacy_policy_page_id: SiteSetting.get('privacy_policy_page_id', ''),
-
allow_user_registration: SiteSetting.get('allow_user_registration', true),
-
default_user_role: SiteSetting.get('default_user_role', 'subscriber'),
-
# Analytics Privacy Settings
-
analytics_enabled: SiteSetting.get('analytics_enabled', true),
-
analytics_require_consent: SiteSetting.get('analytics_require_consent', true),
-
analytics_anonymize_ip: SiteSetting.get('analytics_anonymize_ip', true),
-
analytics_track_bots: SiteSetting.get('analytics_track_bots', false),
-
analytics_data_retention_days: SiteSetting.get('analytics_data_retention_days', 365),
-
analytics_consent_message: SiteSetting.get('analytics_consent_message', 'We use privacy-friendly analytics to understand how you use our site. No personal data is collected.'),
-
# High-Volume Performance Settings
-
analytics_high_volume_mode: SiteSetting.get('analytics_high_volume_mode', false),
-
analytics_archive_enabled: SiteSetting.get('analytics_archive_enabled', true),
-
analytics_batch_size: SiteSetting.get('analytics_batch_size', 1000)
-
}
-
end
-
-
def load_email_settings
-
@settings = {
-
email_provider: SiteSetting.get('email_provider', 'smtp'),
-
email_logging_enabled: SiteSetting.get('email_logging_enabled', true),
-
-
# SMTP
-
smtp_host: SiteSetting.get('smtp_host', 'smtp.gmail.com'),
-
smtp_port: SiteSetting.get('smtp_port', 587),
-
smtp_encryption: SiteSetting.get('smtp_encryption', 'tls'),
-
smtp_username: SiteSetting.get('smtp_username', ''),
-
smtp_password: SiteSetting.get('smtp_password', ''),
-
smtp_timeout: SiteSetting.get('smtp_timeout', 10),
-
-
# Resend
-
resend_api_key: SiteSetting.get('resend_api_key', ''),
-
-
# Default sender
-
default_from_email: SiteSetting.get('default_from_email', 'noreply@railspress.com'),
-
default_from_name: SiteSetting.get('default_from_name', 'RailsPress')
-
}
-
end
-
-
def load_white_label_settings
-
@settings = {
-
admin_app_name: SiteSetting.get('admin_app_name', 'RailsPress'),
-
admin_app_url: SiteSetting.get('admin_app_url', 'http://localhost:3000'),
-
admin_logo_url: SiteSetting.get('admin_logo_url', ''),
-
admin_favicon_url: SiteSetting.get('admin_favicon_url', ''),
-
admin_footer_text: SiteSetting.get('admin_footer_text', 'Powered by RailsPress'),
-
admin_support_email: SiteSetting.get('admin_support_email', 'support@railspress.com'),
-
admin_support_url: SiteSetting.get('admin_support_url', 'https://railspress.com/support'),
-
hide_branding: SiteSetting.get('hide_branding', false)
-
}
-
end
-
-
def load_appearance_settings
-
@settings = {
-
# Color Scheme
-
color_scheme: SiteSetting.get('color_scheme', 'onyx'),
-
-
# Color Accents
-
primary_color: SiteSetting.get('primary_color', '#6366F1'),
-
secondary_color: SiteSetting.get('secondary_color', '#8B5CF6'),
-
-
# Typography
-
heading_font: SiteSetting.get('heading_font', 'Inter'),
-
body_font: SiteSetting.get('body_font', 'Inter'),
-
paragraph_font: SiteSetting.get('paragraph_font', 'Inter'),
-
-
# Font Sizes
-
heading_size: SiteSetting.get('heading_size', '1.875rem'),
-
body_size: SiteSetting.get('body_size', '0.875rem'),
-
paragraph_size: SiteSetting.get('paragraph_size', '1rem')
-
}
-
end
-
-
def load_storage_settings
-
# Get current tenant storage settings if available
-
current_tenant = defined?(ActsAsTenant) ? ActsAsTenant.current_tenant : nil
-
-
@settings = {
-
# Storage Type
-
storage_type: current_tenant&.storage_type || SiteSetting.get('storage_type', 'local'),
-
-
# Local Storage Configuration
-
local_storage_path: SiteSetting.get('local_storage_path', Rails.root.join('storage').to_s),
-
-
# S3 Configuration
-
storage_bucket: current_tenant&.storage_bucket || SiteSetting.get('storage_bucket', ''),
-
storage_region: current_tenant&.storage_region || SiteSetting.get('storage_region', 'us-east-1'),
-
storage_access_key: current_tenant&.storage_access_key || SiteSetting.get('storage_access_key', ''),
-
storage_secret_key: current_tenant&.storage_secret_key || SiteSetting.get('storage_secret_key', ''),
-
storage_endpoint: current_tenant&.storage_endpoint || SiteSetting.get('storage_endpoint', ''),
-
storage_path: current_tenant&.storage_path || SiteSetting.get('storage_path', ''),
-
-
# General Storage Settings
-
enable_cdn: SiteSetting.get('enable_cdn', false),
-
cdn_url: SiteSetting.get('cdn_url', ''),
-
auto_optimize_uploads: SiteSetting.get('auto_optimize_uploads', true),
-
max_file_size: SiteSetting.get('max_file_size', 10), # MB
-
allowed_file_types: SiteSetting.get('allowed_file_types', 'jpg,jpeg,png,gif,pdf,doc,docx,mp4,mp3')
-
}
-
end
-
-
def configure_action_mailer
-
provider = SiteSetting.get('email_provider', 'smtp')
-
-
if provider == 'smtp'
-
ActionMailer::Base.delivery_method = :smtp
-
ActionMailer::Base.smtp_settings = {
-
address: SiteSetting.get('smtp_host', 'smtp.gmail.com'),
-
port: SiteSetting.get('smtp_port', 587).to_i,
-
domain: SiteSetting.get('site_url', 'localhost'),
-
user_name: SiteSetting.get('smtp_username', ''),
-
password: SiteSetting.get('smtp_password', ''),
-
authentication: 'plain',
-
enable_starttls_auto: SiteSetting.get('smtp_encryption', 'tls') == 'tls',
-
open_timeout: SiteSetting.get('smtp_timeout', 10).to_i,
-
read_timeout: SiteSetting.get('smtp_timeout', 10).to_i
-
}
-
elsif provider == 'resend'
-
# Resend uses its own delivery method
-
ActionMailer::Base.delivery_method = :resend
-
end
-
end
-
-
def setting_type_for(key)
-
boolean_settings = %w[
-
enable_auto_save enable_revisions auto_optimize_images use_trailing_slash
-
auto_redirect_old_urls comments_enabled comments_moderation
-
comment_registration_required show_avatars gdpr_compliance_enabled
-
cookie_consent_required allow_user_registration email_logging_enabled
-
hide_branding enable_cdn auto_optimize_uploads strip_image_metadata
-
enable_webp_variants enable_avif_variants analytics_enabled
-
analytics_require_consent analytics_anonymize_ip analytics_track_bots
-
]
-
-
integer_settings = %w[
-
auto_save_interval max_revisions posts_per_page posts_per_rss
-
image_max_width image_max_height thumbnail_width thumbnail_height
-
medium_width medium_height large_width large_height max_upload_size
-
close_comments_after_days excerpt_length smtp_port smtp_timeout
-
max_file_size image_quality image_compression_level_value
-
analytics_data_retention_days
-
]
-
-
if boolean_settings.include?(key)
-
'boolean'
-
elsif integer_settings.include?(key)
-
'integer'
-
else
-
'string'
-
end
-
end
-
-
def store_logo(file)
-
# For now, just return a placeholder
-
# In production, you'd upload to ActiveStorage or external service
-
return '/uploads/logo.png'
-
end
-
-
def shortcuts
-
# Load command palette shortcut settings
-
@command_palette_shortcut = SiteSetting.get('command_palette_shortcut', 'cmd+k')
-
end
-
-
def update_shortcuts
-
shortcut = params[:command_palette_shortcut]
-
-
# Validate shortcut format
-
valid_shortcuts = ['cmd+k', 'ctrl+k', 'cmd+shift+p', 'ctrl+shift+p', 'cmd+i', 'ctrl+i']
-
unless valid_shortcuts.include?(shortcut)
-
redirect_to admin_shortcuts_settings_path, alert: 'Invalid shortcut format'
-
return
-
end
-
-
SiteSetting.set('command_palette_shortcut', shortcut)
-
redirect_to admin_shortcuts_settings_path, notice: 'Shortcuts updated successfully!'
-
end
-
-
# JSON endpoint for JavaScript to get shortcut settings
-
def shortcuts_json
-
render json: {
-
command_palette_shortcut: SiteSetting.get('command_palette_shortcut', 'cmd+k')
-
}
-
end
-
-
def ensure_admin
-
redirect_to admin_root_path, alert: 'Access denied.' unless current_user&.administrator?
-
end
-
end
-
-
class Admin::ShortcodesController < Admin::BaseController
-
-
# GET /admin/shortcodes
-
def index
-
@shortcodes = build_shortcode_list
-
end
-
-
# POST /admin/shortcodes/test
-
def test
-
content = params[:content]
-
result = Railspress::ShortcodeProcessor.process(content)
-
-
render json: {
-
success: true,
-
original: content,
-
processed: result
-
}
-
end
-
-
private
-
-
def build_shortcode_list
-
[
-
{
-
name: 'gallery',
-
description: 'Display a gallery of images',
-
usage: '[gallery ids="1,2,3" columns="3" size="medium"]',
-
attributes: [
-
{ name: 'ids', description: 'Comma-separated media IDs', required: true },
-
{ name: 'columns', description: 'Number of columns (1-6)', default: '3' },
-
{ name: 'size', description: 'Image size (thumbnail, medium, large)', default: 'medium' }
-
],
-
category: 'Media'
-
},
-
{
-
name: 'button',
-
description: 'Create a styled button/link',
-
usage: '[button url="/contact" style="primary" size="medium"]Click Me[/button]',
-
attributes: [
-
{ name: 'url', description: 'Button URL', required: true },
-
{ name: 'style', description: 'Button style (primary, secondary, success, danger)', default: 'primary' },
-
{ name: 'size', description: 'Button size (small, medium, large)', default: 'medium' },
-
{ name: 'target', description: 'Link target (_self, _blank)', default: '_self' }
-
],
-
category: 'Content'
-
},
-
{
-
name: 'youtube',
-
description: 'Embed a YouTube video',
-
usage: '[youtube id="VIDEO_ID" width="560" height="315"]',
-
attributes: [
-
{ name: 'id', description: 'YouTube video ID', required: true },
-
{ name: 'width', description: 'Video width', default: '560' },
-
{ name: 'height', description: 'Video height', default: '315' }
-
],
-
category: 'Media'
-
},
-
{
-
name: 'recent_posts',
-
description: 'Display recent posts',
-
usage: '[recent_posts count="5" category="technology"]',
-
attributes: [
-
{ name: 'count', description: 'Number of posts to show', default: '5' },
-
{ name: 'category', description: 'Filter by category slug', required: false }
-
],
-
category: 'Content'
-
},
-
{
-
name: 'contact_form',
-
description: 'Display a contact form',
-
usage: '[contact_form id="contact" email="admin@example.com"]',
-
attributes: [
-
{ name: 'id', description: 'Form ID', default: 'contact' },
-
{ name: 'email', description: 'Recipient email', required: false }
-
],
-
category: 'Forms'
-
},
-
{
-
name: 'columns',
-
description: 'Create column layout',
-
usage: '[columns count="2"]Content here[/columns]',
-
attributes: [
-
{ name: 'count', description: 'Number of columns (2-4)', default: '2' }
-
],
-
category: 'Layout'
-
},
-
{
-
name: 'alert',
-
description: 'Display an alert/notice box',
-
usage: '[alert type="info"]Your message here[/alert]',
-
attributes: [
-
{ name: 'type', description: 'Alert type (info, success, warning, danger)', default: 'info' }
-
],
-
category: 'Content'
-
},
-
{
-
name: 'code',
-
description: 'Display code block with syntax highlighting',
-
usage: '[code lang="ruby"]puts "Hello World"[/code]',
-
attributes: [
-
{ name: 'lang', description: 'Programming language', default: 'plaintext' }
-
],
-
category: 'Content'
-
}
-
]
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::SiteSettingsController < Admin::BaseController
-
before_action :set_site_setting, only: %i[ show edit update destroy ]
-
-
# GET /admin/site_settings or /admin/site_settings.json
-
def index
-
@site_settings = SiteSetting.all
-
end
-
-
# GET /admin/site_settings/1 or /admin/site_settings/1.json
-
def show
-
end
-
-
# GET /admin/site_settings/new
-
def new
-
@site_setting = SiteSetting.new
-
end
-
-
# GET /admin/site_settings/1/edit
-
def edit
-
end
-
-
# POST /admin/site_settings or /admin/site_settings.json
-
def create
-
@site_setting = SiteSetting.new(site_setting_params)
-
-
respond_to do |format|
-
if @site_setting.save
-
format.html { redirect_to [:admin, @site_setting], notice: "Site setting was successfully created." }
-
format.json { render :show, status: :created, location: @site_setting }
-
else
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @site_setting.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/site_settings/1 or /admin/site_settings/1.json
-
def update
-
respond_to do |format|
-
if @site_setting.update(site_setting_params)
-
format.html { redirect_to [:admin, @site_setting], notice: "Site setting was successfully updated.", status: :see_other }
-
format.json { render :show, status: :ok, location: @site_setting }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @site_setting.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/site_settings/1 or /admin/site_settings/1.json
-
def destroy
-
@site_setting.destroy!
-
-
respond_to do |format|
-
format.html { redirect_to admin_site_settings_path, notice: "Site setting was successfully destroyed.", status: :see_other }
-
format.json { head :no_content }
-
end
-
end
-
-
private
-
# Use callbacks to share common setup or constraints between actions.
-
def set_site_setting
-
@site_setting = SiteSetting.find(params[:id])
-
end
-
-
# Only allow a list of trusted parameters through.
-
def site_setting_params
-
params.fetch(:site_setting, {})
-
end
-
end
-
# SlickForms Admin Controller
-
# Handles form management in the admin panel
-
-
class Admin::SlickForms::FormsController < Admin::BaseController
-
before_action :set_form, only: [:show, :edit, :update, :destroy, :duplicate, :preview]
-
-
def index
-
@forms = get_all_forms
-
@stats = {
-
total_forms: @forms.size,
-
total_submissions: get_submission_count,
-
active_forms: @forms.count { |f| f[:active] }
-
}
-
end
-
-
def show
-
@submissions = get_form_submissions(@form[:id])
-
@stats = {
-
total_submissions: @submissions.size,
-
today_submissions: get_today_submissions(@form[:id]),
-
conversion_rate: calculate_conversion_rate(@form[:id])
-
}
-
end
-
-
def new
-
@form = {
-
name: '',
-
title: '',
-
description: '',
-
fields: [],
-
settings: {},
-
active: true
-
}
-
end
-
-
def create
-
form_data = form_params
-
-
# Create form record
-
form_id = create_form_record(form_data)
-
-
if form_id
-
redirect_to admin_slick_forms_form_path(form_id), notice: 'Form was successfully created.'
-
else
-
render :new, alert: 'Failed to create form.'
-
end
-
end
-
-
def edit
-
# Form data is already loaded in set_form
-
end
-
-
def update
-
if update_form_record(@form[:id], form_params)
-
redirect_to admin_slick_forms_form_path(@form[:id]), notice: 'Form was successfully updated.'
-
else
-
render :edit, alert: 'Failed to update form.'
-
end
-
end
-
-
def destroy
-
if delete_form_record(@form[:id])
-
redirect_to admin_slick_forms_forms_path, notice: 'Form was successfully deleted.'
-
else
-
redirect_to admin_slick_forms_forms_path, alert: 'Failed to delete form.'
-
end
-
end
-
-
def duplicate
-
new_form_id = duplicate_form_record(@form[:id])
-
if new_form_id
-
redirect_to admin_slick_forms_form_path(new_form_id), notice: 'Form was successfully duplicated.'
-
else
-
redirect_to admin_slick_forms_forms_path, alert: 'Failed to duplicate form.'
-
end
-
end
-
-
def preview
-
# Render form preview
-
render layout: 'admin'
-
end
-
-
def import
-
# Handle form import
-
redirect_to admin_slick_forms_forms_path, notice: 'Form import feature coming soon.'
-
end
-
-
private
-
-
def set_form
-
@form = get_form_by_id(params[:id])
-
redirect_to admin_slick_forms_forms_path, alert: 'Form not found.' unless @form
-
end
-
-
def form_params
-
params.require(:form).permit(:name, :title, :description, :active, fields: [], settings: {})
-
end
-
-
# Private helper methods using ActiveRecord models
-
def get_all_forms
-
SlickForm.accessible_by(current_tenant).order(created_at: :desc)
-
end
-
-
def get_form_by_id(id)
-
SlickForm.accessible_by(current_tenant).find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
nil
-
end
-
-
def create_form_record(data)
-
form = SlickForm.new(data)
-
form.tenant = current_tenant if respond_to?(:current_tenant)
-
form.save ? form.id : nil
-
end
-
-
def update_form_record(id, data)
-
form = SlickForm.accessible_by(current_tenant).find(id)
-
form.update(data)
-
rescue ActiveRecord::RecordNotFound
-
false
-
end
-
-
def delete_form_record(id)
-
form = SlickForm.accessible_by(current_tenant).find(id)
-
form.destroy
-
true
-
rescue ActiveRecord::RecordNotFound
-
false
-
end
-
-
def duplicate_form_record(id)
-
form = SlickForm.accessible_by(current_tenant).find(id)
-
new_form = form.dup
-
new_form.name = "#{form.name} (Copy)"
-
new_form.title = "#{form.title} (Copy)"
-
new_form.submissions_count = 0
-
new_form.save ? new_form.id : nil
-
rescue ActiveRecord::RecordNotFound
-
nil
-
end
-
-
def get_form_submissions(form_id)
-
SlickFormSubmission.where(slick_form_id: form_id)
-
.accessible_by(current_tenant)
-
.recent
-
end
-
-
def get_submission_count
-
SlickFormSubmission.accessible_by(current_tenant).ham.count
-
end
-
-
def get_today_submissions(form_id)
-
SlickFormSubmission.where(slick_form_id: form_id)
-
.accessible_by(current_tenant)
-
.ham
-
.where('DATE(created_at) = ?', Date.today)
-
.count
-
end
-
-
def calculate_conversion_rate(form_id)
-
# This would calculate form views vs submissions
-
# For now, return a placeholder
-
0.0
-
end
-
end
-
# SlickForms Submissions Controller
-
# Handles form submission management in the admin panel
-
-
class Admin::SlickForms::SubmissionsController < Admin::BaseController
-
before_action :set_submission, only: [:show, :destroy]
-
-
def index
-
@submissions = get_recent_submissions(50)
-
@stats = {
-
total: get_submission_count,
-
today: get_submissions_today_count,
-
this_week: get_submissions_week_count
-
}
-
-
# Handle bulk actions
-
if params[:bulk_action].present? && params[:submission_ids].present?
-
handle_bulk_action
-
end
-
end
-
-
def show
-
@form = get_form_by_id(@submission[:slick_form_id])
-
end
-
-
def destroy
-
if delete_submission(@submission[:id])
-
redirect_to admin_slick_forms_submissions_path, notice: 'Submission was successfully deleted.'
-
else
-
redirect_to admin_slick_forms_submissions_path, alert: 'Failed to delete submission.'
-
end
-
end
-
-
def export
-
submissions = get_all_submissions
-
csv_data = generate_csv(submissions)
-
-
respond_to do |format|
-
format.csv { send_data csv_data, filename: "slick_forms_submissions_#{Date.today}.csv" }
-
end
-
end
-
-
def bulk_action
-
case params[:bulk_action]
-
when 'delete'
-
bulk_delete_submissions(params[:submission_ids])
-
redirect_to admin_slick_forms_submissions_path, notice: 'Selected submissions were deleted.'
-
when 'mark_spam'
-
bulk_mark_spam(params[:submission_ids])
-
redirect_to admin_slick_forms_submissions_path, notice: 'Selected submissions were marked as spam.'
-
when 'mark_ham'
-
bulk_mark_ham(params[:submission_ids])
-
redirect_to admin_slick_forms_submissions_path, notice: 'Selected submissions were marked as legitimate.'
-
else
-
redirect_to admin_slick_forms_submissions_path, alert: 'Invalid bulk action.'
-
end
-
end
-
-
private
-
-
def set_submission
-
@submission = get_submission_by_id(params[:id])
-
redirect_to admin_slick_forms_submissions_path, alert: 'Submission not found.' unless @submission
-
end
-
-
def handle_bulk_action
-
case params[:bulk_action]
-
when 'delete'
-
bulk_delete_submissions(params[:submission_ids])
-
flash[:notice] = 'Selected submissions were deleted.'
-
when 'mark_spam'
-
bulk_mark_spam(params[:submission_ids])
-
flash[:notice] = 'Selected submissions were marked as spam.'
-
when 'mark_ham'
-
bulk_mark_ham(params[:submission_ids])
-
flash[:notice] = 'Selected submissions were marked as legitimate.'
-
end
-
end
-
-
# Database operations
-
def get_recent_submissions(limit = 50)
-
return [] unless table_exists?('slick_form_submissions')
-
ActiveRecord::Base.connection.execute(
-
"SELECT * FROM slick_form_submissions ORDER BY created_at DESC LIMIT #{limit}"
-
).to_a.map(&:symbolize_keys)
-
end
-
-
def get_all_submissions
-
return [] unless table_exists?('slick_form_submissions')
-
ActiveRecord::Base.connection.execute(
-
"SELECT * FROM slick_form_submissions ORDER BY created_at DESC"
-
).to_a.map(&:symbolize_keys)
-
end
-
-
def get_submission_by_id(id)
-
return nil unless table_exists?('slick_form_submissions')
-
result = ActiveRecord::Base.connection.execute(
-
"SELECT * FROM slick_form_submissions WHERE id = #{id}"
-
).first
-
result&.symbolize_keys
-
end
-
-
def delete_submission(id)
-
return false unless table_exists?('slick_form_submissions')
-
-
ActiveRecord::Base.connection.execute(
-
"DELETE FROM slick_form_submissions WHERE id = #{id}"
-
)
-
-
true
-
end
-
-
def bulk_delete_submissions(ids)
-
return unless table_exists?('slick_form_submissions')
-
-
ids = ids.reject(&:blank?)
-
return if ids.empty?
-
-
ActiveRecord::Base.connection.execute(
-
"DELETE FROM slick_form_submissions WHERE id IN (#{ids.join(',')})"
-
)
-
end
-
-
def bulk_mark_spam(ids)
-
return unless table_exists?('slick_form_submissions')
-
-
ids = ids.reject(&:blank?)
-
return if ids.empty?
-
-
ActiveRecord::Base.connection.execute(
-
"UPDATE slick_form_submissions SET spam = 1 WHERE id IN (#{ids.join(',')})"
-
)
-
end
-
-
def bulk_mark_ham(ids)
-
return unless table_exists?('slick_form_submissions')
-
-
ids = ids.reject(&:blank?)
-
return if ids.empty?
-
-
ActiveRecord::Base.connection.execute(
-
"UPDATE slick_form_submissions SET spam = 0 WHERE id IN (#{ids.join(',')})"
-
)
-
end
-
-
def get_submission_count
-
return 0 unless table_exists?('slick_form_submissions')
-
ActiveRecord::Base.connection.execute("SELECT COUNT(*) as count FROM slick_form_submissions WHERE spam = 0").first['count']
-
end
-
-
def get_submissions_today_count
-
return 0 unless table_exists?('slick_form_submissions')
-
today = Date.today.to_s
-
ActiveRecord::Base.connection.execute(
-
"SELECT COUNT(*) as count FROM slick_form_submissions WHERE DATE(created_at) = '#{today}' AND spam = 0"
-
).first['count']
-
end
-
-
def get_submissions_week_count
-
return 0 unless table_exists?('slick_form_submissions')
-
week_ago = 7.days.ago.to_s
-
ActiveRecord::Base.connection.execute(
-
"SELECT COUNT(*) as count FROM slick_form_submissions WHERE created_at >= '#{week_ago}' AND spam = 0"
-
).first['count']
-
end
-
-
def get_form_by_id(id)
-
return nil unless table_exists?('slick_forms')
-
result = ActiveRecord::Base.connection.execute(
-
"SELECT * FROM slick_forms WHERE id = #{id}"
-
).first
-
result&.symbolize_keys
-
end
-
-
def generate_csv(submissions)
-
require 'csv'
-
-
CSV.generate do |csv|
-
# Header
-
csv << ['ID', 'Form ID', 'Form Name', 'Data', 'IP Address', 'User Agent', 'Spam', 'Created At']
-
-
# Data rows
-
submissions.each do |submission|
-
form = get_form_by_id(submission[:slick_form_id])
-
csv << [
-
submission[:id],
-
submission[:slick_form_id],
-
form&.[](:name) || 'Unknown',
-
submission[:data],
-
submission[:ip_address],
-
submission[:user_agent],
-
submission[:spam] ? 'Yes' : 'No',
-
submission[:created_at]
-
]
-
end
-
end
-
end
-
-
def table_exists?(table_name)
-
ActiveRecord::Base.connection.table_exists?(table_name)
-
end
-
end
-
-
-
-
-
# Admin::FluentFormsController
-
# Admin interface for managing forms, entries, and settings
-
-
class Admin::FluentFormsController < Admin::BaseController
-
before_action :set_form, only: [:edit, :update, :destroy, :duplicate, :toggle_status]
-
before_action :set_plugin, only: [:index, :new, :create, :settings, :update_settings]
-
-
# GET /admin/fluent-forms
-
def index
-
@forms = fetch_all_forms
-
@stats = calculate_stats
-
end
-
-
# GET /admin/fluent-forms/new
-
def new
-
@form_templates = form_templates
-
end
-
-
# POST /admin/fluent-forms/create
-
def create
-
template_type = params[:template_type] || 'blank'
-
-
form_data = {
-
title: params[:title] || 'Untitled Form',
-
form_fields: get_template_fields(template_type).to_json,
-
settings: default_form_settings.to_json,
-
appearance_settings: default_appearance_settings.to_json,
-
status: 'draft',
-
form_type: 'form',
-
has_payment: false,
-
conditions: {}.to_json,
-
created_by: current_user.id
-
}
-
-
ActiveRecord::Base.connection.execute(
-
"INSERT INTO ff_forms (title, form_fields, settings, appearance_settings, status, form_type,
-
has_payment, conditions, created_by, created_at, updated_at)
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
-
form_data[:title],
-
form_data[:form_fields],
-
form_data[:settings],
-
form_data[:appearance_settings],
-
form_data[:status],
-
form_data[:form_type],
-
form_data[:has_payment],
-
form_data[:conditions],
-
form_data[:created_by],
-
Time.current,
-
Time.current
-
)
-
-
form_id = ActiveRecord::Base.connection.last_inserted_row_id
-
-
redirect_to edit_admin_fluent_form_path(form_id), notice: 'Form created successfully!'
-
rescue => e
-
Rails.logger.error "[Fluent Forms Admin] Create error: #{e.message}"
-
redirect_to admin_fluent_forms_path, alert: 'Failed to create form'
-
end
-
-
# GET /admin/fluent-forms/:id/edit
-
def edit
-
@form = @form_data
-
@field_types = field_types
-
@integrations = available_integrations
-
end
-
-
# PATCH /admin/fluent-forms/:id
-
def update
-
update_params = {
-
title: params[:title],
-
form_fields: params[:form_fields],
-
settings: params[:settings],
-
appearance_settings: params[:appearance_settings],
-
status: params[:status],
-
has_payment: params[:has_payment],
-
conditions: params[:conditions]
-
}
-
-
sql_parts = []
-
sql_values = []
-
-
update_params.each do |key, value|
-
next if value.nil?
-
sql_parts << "#{key} = ?"
-
sql_values << value
-
end
-
-
sql_values << Time.current
-
sql_parts << "updated_at = ?"
-
-
sql_values << params[:id]
-
-
ActiveRecord::Base.connection.execute(
-
"UPDATE ff_forms SET #{sql_parts.join(', ')} WHERE id = ?",
-
*sql_values
-
)
-
-
if request.xhr?
-
render json: { success: true, message: 'Form updated successfully!' }
-
else
-
redirect_to edit_admin_fluent_form_path(params[:id]), notice: 'Form updated successfully!'
-
end
-
rescue => e
-
Rails.logger.error "[Fluent Forms Admin] Update error: #{e.message}"
-
-
if request.xhr?
-
render json: { success: false, message: 'Failed to update form' }, status: 422
-
else
-
redirect_to edit_admin_fluent_form_path(params[:id]), alert: 'Failed to update form'
-
end
-
end
-
-
# DELETE /admin/fluent-forms/:id
-
def destroy
-
ActiveRecord::Base.connection.execute("DELETE FROM ff_forms WHERE id = ?", params[:id])
-
-
redirect_to admin_fluent_forms_path, notice: 'Form deleted successfully!'
-
rescue => e
-
Rails.logger.error "[Fluent Forms Admin] Delete error: #{e.message}"
-
redirect_to admin_fluent_forms_path, alert: 'Failed to delete form'
-
end
-
-
# POST /admin/fluent-forms/:id/duplicate
-
def duplicate
-
new_title = "#{@form_data[:title]} (Copy)"
-
-
ActiveRecord::Base.connection.execute(
-
"INSERT INTO ff_forms (title, form_fields, settings, appearance_settings, status, form_type,
-
has_payment, conditions, created_by, created_at, updated_at)
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
-
new_title,
-
@form_data[:form_fields].to_json,
-
@form_data[:settings].to_json,
-
@form_data[:appearance_settings].to_json,
-
'draft',
-
@form_data[:form_type],
-
@form_data[:has_payment],
-
@form_data[:conditions].to_json,
-
current_user.id,
-
Time.current,
-
Time.current
-
)
-
-
redirect_to admin_fluent_forms_path, notice: 'Form duplicated successfully!'
-
rescue => e
-
Rails.logger.error "[Fluent Forms Admin] Duplicate error: #{e.message}"
-
redirect_to admin_fluent_forms_path, alert: 'Failed to duplicate form'
-
end
-
-
# POST /admin/fluent-forms/:id/toggle-status
-
def toggle_status
-
new_status = @form_data[:status] == 'published' ? 'draft' : 'published'
-
-
ActiveRecord::Base.connection.execute(
-
"UPDATE ff_forms SET status = ?, updated_at = ? WHERE id = ?",
-
new_status,
-
Time.current,
-
params[:id]
-
)
-
-
render json: { success: true, status: new_status }
-
rescue => e
-
render json: { success: false, message: e.message }, status: 422
-
end
-
-
# GET /admin/fluent-forms/entries
-
def entries
-
@form_id = params[:form_id]
-
@entries = fetch_entries(@form_id)
-
@forms = fetch_all_forms
-
@filters = {
-
status: params[:status],
-
date_range: params[:date_range],
-
search: params[:search]
-
}
-
end
-
-
# GET /admin/fluent-forms/entries/:id
-
def entry_details
-
@entry = fetch_entry_details(params[:id])
-
@form = fetch_form(@entry[:form_id])
-
end
-
-
# POST /admin/fluent-forms/entries/:id/mark-read
-
def mark_entry_read
-
update_entry_status(params[:id], 'read')
-
render json: { success: true }
-
end
-
-
# POST /admin/fluent-forms/entries/:id/favorite
-
def toggle_favorite
-
entry = fetch_entry_details(params[:id])
-
new_favorite = !entry[:is_favorite]
-
-
ActiveRecord::Base.connection.execute(
-
"UPDATE ff_submissions SET is_favorite = ?, updated_at = ? WHERE id = ?",
-
new_favorite,
-
Time.current,
-
params[:id]
-
)
-
-
render json: { success: true, is_favorite: new_favorite }
-
end
-
-
# DELETE /admin/fluent-forms/entries/:id
-
def delete_entry
-
ActiveRecord::Base.connection.execute("DELETE FROM ff_submissions WHERE id = ?", params[:id])
-
redirect_to admin_fluent_forms_entries_path, notice: 'Entry deleted successfully!'
-
end
-
-
# GET /admin/fluent-forms/entries/export
-
def export_entries
-
form_id = params[:form_id]
-
format = params[:format] || 'csv'
-
-
entries = fetch_entries(form_id, limit: nil)
-
-
case format
-
when 'csv'
-
send_data generate_csv(entries), filename: "entries-#{form_id}-#{Time.current.to_i}.csv"
-
when 'json'
-
send_data entries.to_json, filename: "entries-#{form_id}-#{Time.current.to_i}.json"
-
else
-
redirect_to admin_fluent_forms_entries_path, alert: 'Invalid export format'
-
end
-
end
-
-
# GET /admin/fluent-forms/analytics
-
def analytics
-
@form_id = params[:form_id]
-
@date_range = params[:date_range] || '30_days'
-
@analytics_data = calculate_analytics(@form_id, @date_range)
-
@forms = fetch_all_forms
-
end
-
-
# GET /admin/fluent-forms/integrations
-
def integrations
-
@integrations = available_integrations
-
@active_integrations = get_active_integrations
-
end
-
-
# POST /admin/fluent-forms/integrations/:integration/toggle
-
def toggle_integration
-
integration_name = params[:integration]
-
# Toggle integration logic here
-
render json: { success: true }
-
end
-
-
# GET /admin/fluent-forms/settings
-
def settings
-
@settings = @plugin.get_all_settings
-
@tabs = ['general', 'email', 'payments', 'spam_protection', 'file_uploads', 'integrations']
-
end
-
-
# PATCH /admin/fluent-forms/settings
-
def update_settings
-
settings_params = params.require(:settings).permit!
-
-
settings_params.each do |key, value|
-
@plugin.set_setting(key, value)
-
end
-
-
redirect_to admin_fluent_forms_settings_path, notice: 'Settings updated successfully!'
-
rescue => e
-
Rails.logger.error "[Fluent Forms Admin] Settings update error: #{e.message}"
-
redirect_to admin_fluent_forms_settings_path, alert: 'Failed to update settings'
-
end
-
-
private
-
-
def set_form
-
@form_data = fetch_form(params[:id])
-
redirect_to admin_fluent_forms_path, alert: 'Form not found' unless @form_data
-
end
-
-
def set_plugin
-
@plugin = FluentFormsPro.new
-
end
-
-
def fetch_all_forms
-
results = ActiveRecord::Base.connection.execute("SELECT * FROM ff_forms ORDER BY created_at DESC")
-
results.map do |row|
-
{
-
id: row[0],
-
title: row[1],
-
status: row[4],
-
form_type: row[6],
-
has_payment: row[7],
-
created_at: row[10],
-
updated_at: row[11],
-
submission_count: count_submissions(row[0])
-
}
-
end
-
rescue => e
-
Rails.logger.error "[Fluent Forms Admin] Fetch forms error: #{e.message}"
-
[]
-
end
-
-
def fetch_form(form_id)
-
result = ActiveRecord::Base.connection.execute(
-
"SELECT * FROM ff_forms WHERE id = ? LIMIT 1",
-
form_id
-
).first
-
-
return nil unless result
-
-
{
-
id: result[0],
-
title: result[1],
-
form_fields: JSON.parse(result[2] || '{}'),
-
settings: JSON.parse(result[3] || '{}'),
-
status: result[4],
-
appearance_settings: result[5] ? JSON.parse(result[5]) : {},
-
form_type: result[6],
-
has_payment: result[7],
-
conditions: result[8] ? JSON.parse(result[8]) : {},
-
created_at: result[10],
-
updated_at: result[11]
-
}
-
rescue => e
-
Rails.logger.error "[Fluent Forms Admin] Fetch form error: #{e.message}"
-
nil
-
end
-
-
def fetch_entries(form_id, options = {})
-
limit = options[:limit] || 50
-
-
query = if form_id
-
"SELECT * FROM ff_submissions WHERE form_id = ? ORDER BY created_at DESC"
-
else
-
"SELECT * FROM ff_submissions ORDER BY created_at DESC"
-
end
-
-
query += " LIMIT #{limit}" if limit
-
-
results = if form_id
-
ActiveRecord::Base.connection.execute(query, form_id)
-
else
-
ActiveRecord::Base.connection.execute(query)
-
end
-
-
results.map do |row|
-
{
-
id: row[0],
-
form_id: row[1],
-
serial_number: row[2],
-
response_data: JSON.parse(row[3] || '{}'),
-
source_url: row[4],
-
user_id: row[5],
-
status: row[12],
-
is_favorite: row[13],
-
created_at: row[14]
-
}
-
end
-
rescue => e
-
Rails.logger.error "[Fluent Forms Admin] Fetch entries error: #{e.message}"
-
[]
-
end
-
-
def fetch_entry_details(entry_id)
-
result = ActiveRecord::Base.connection.execute(
-
"SELECT * FROM ff_submissions WHERE id = ? LIMIT 1",
-
entry_id
-
).first
-
-
return nil unless result
-
-
{
-
id: result[0],
-
form_id: result[1],
-
serial_number: result[2],
-
response_data: JSON.parse(result[3] || '{}'),
-
source_url: result[4],
-
user_id: result[5],
-
browser: result[6],
-
device: result[7],
-
ip_address: result[8],
-
city: result[9],
-
country: result[10],
-
payment_status: result[11],
-
status: result[12],
-
is_favorite: result[13],
-
created_at: result[14],
-
updated_at: result[15]
-
}
-
end
-
-
def count_submissions(form_id)
-
result = ActiveRecord::Base.connection.execute(
-
"SELECT COUNT(*) FROM ff_submissions WHERE form_id = ?",
-
form_id
-
).first
-
-
result.first
-
rescue
-
0
-
end
-
-
def update_entry_status(entry_id, status)
-
ActiveRecord::Base.connection.execute(
-
"UPDATE ff_submissions SET status = ?, updated_at = ? WHERE id = ?",
-
status,
-
Time.current,
-
entry_id
-
)
-
end
-
-
def calculate_stats
-
{
-
total_forms: ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM ff_forms").first.first,
-
total_submissions: ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM ff_submissions").first.first,
-
unread_submissions: ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM ff_submissions WHERE status = 'unread'").first.first,
-
forms_with_payments: ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM ff_forms WHERE has_payment = 1").first.first
-
}
-
rescue
-
{ total_forms: 0, total_submissions: 0, unread_submissions: 0, forms_with_payments: 0 }
-
end
-
-
def calculate_analytics(form_id, date_range)
-
# Analytics calculation logic
-
{
-
views: rand(100..1000),
-
submissions: count_submissions(form_id),
-
conversion_rate: rand(10..50),
-
average_completion_time: rand(30..180)
-
}
-
end
-
-
def generate_csv(entries)
-
require 'csv'
-
-
CSV.generate do |csv|
-
# Header row
-
if entries.any?
-
headers = ['ID', 'Serial Number', 'Status', 'Created At']
-
headers += entries.first[:response_data].keys
-
csv << headers
-
-
# Data rows
-
entries.each do |entry|
-
row = [
-
entry[:id],
-
entry[:serial_number],
-
entry[:status],
-
entry[:created_at]
-
]
-
row += entry[:response_data].values
-
csv << row
-
end
-
end
-
end
-
end
-
-
def form_templates
-
[
-
{ id: 'blank', name: 'Blank Form', description: 'Start from scratch' },
-
{ id: 'contact', name: 'Contact Form', description: 'Simple contact form with name, email, and message' },
-
{ id: 'registration', name: 'Registration Form', description: 'User registration with multiple fields' },
-
{ id: 'survey', name: 'Survey Form', description: 'Survey with multiple choice questions' },
-
{ id: 'order', name: 'Order Form', description: 'Product order form with payment' },
-
{ id: 'booking', name: 'Booking Form', description: 'Appointment booking form' },
-
{ id: 'feedback', name: 'Feedback Form', description: 'Customer feedback form' },
-
{ id: 'application', name: 'Application Form', description: 'Job or program application' },
-
{ id: 'newsletter', name: 'Newsletter Signup', description: 'Simple email capture form' },
-
{ id: 'quiz', name: 'Quiz Form', description: 'Quiz with scoring' }
-
]
-
end
-
-
def get_template_fields(template_type)
-
case template_type
-
when 'contact'
-
contact_form_template
-
when 'registration'
-
registration_form_template
-
when 'survey'
-
survey_form_template
-
else
-
blank_form_template
-
end
-
end
-
-
def blank_form_template
-
{
-
fields: [],
-
submitButton: default_submit_button
-
}
-
end
-
-
def contact_form_template
-
{
-
fields: [
-
text_field('name', 'Name', true),
-
email_field('email', 'Email', true),
-
textarea_field('message', 'Message', true)
-
],
-
submitButton: default_submit_button
-
}
-
end
-
-
def registration_form_template
-
{
-
fields: [
-
text_field('first_name', 'First Name', true),
-
text_field('last_name', 'Last Name', true),
-
email_field('email', 'Email', true),
-
text_field('phone', 'Phone Number', false),
-
textarea_field('address', 'Address', false)
-
],
-
submitButton: default_submit_button
-
}
-
end
-
-
def survey_form_template
-
{
-
fields: [
-
text_field('name', 'Your Name', true),
-
radio_field('satisfaction', 'How satisfied are you?', ['Very Satisfied', 'Satisfied', 'Neutral', 'Dissatisfied'], true),
-
textarea_field('comments', 'Additional Comments', false)
-
],
-
submitButton: default_submit_button
-
}
-
end
-
-
def text_field(name, label, required)
-
{
-
index: rand(1000),
-
element: 'input_text',
-
attributes: { name: name, 'data-required': required, 'data-type': 'text' },
-
settings: {
-
label: label,
-
label_placement: 'top',
-
admin_field_label: label,
-
validation_rules: required ? { required: { value: true, message: "#{label} is required" } } : {}
-
}
-
}
-
end
-
-
def email_field(name, label, required)
-
{
-
index: rand(1000),
-
element: 'input_email',
-
attributes: { name: name, 'data-required': required, 'data-type': 'email' },
-
settings: {
-
label: label,
-
label_placement: 'top',
-
admin_field_label: label,
-
validation_rules: {
-
required: { value: true, message: "#{label} is required" },
-
email: { value: true, message: 'Please enter a valid email' }
-
}
-
}
-
}
-
end
-
-
def textarea_field(name, label, required)
-
{
-
index: rand(1000),
-
element: 'textarea',
-
attributes: { name: name, 'data-required': required, 'data-type': 'text', rows: 4 },
-
settings: {
-
label: label,
-
label_placement: 'top',
-
admin_field_label: label,
-
validation_rules: required ? { required: { value: true, message: "#{label} is required" } } : {}
-
}
-
}
-
end
-
-
def radio_field(name, label, options, required)
-
{
-
index: rand(1000),
-
element: 'input_radio',
-
attributes: { name: name, 'data-required': required, 'data-type': 'radio' },
-
settings: {
-
label: label,
-
label_placement: 'top',
-
admin_field_label: label,
-
options: options.map { |opt| { label: opt, value: opt.parameterize } },
-
validation_rules: required ? { required: { value: true, message: "#{label} is required" } } : {}
-
}
-
}
-
end
-
-
def default_submit_button
-
{
-
uniqElKey: 'el_submit',
-
element: 'button',
-
attributes: { type: 'submit', class: 'ff-btn ff-btn-submit ff-btn-md' },
-
settings: {
-
align: 'left',
-
button_style: 'default',
-
button_size: 'md',
-
background_color: '#409EFF',
-
color: '#ffffff',
-
button_ui: { type: 'default', text: 'Submit', img_url: '' }
-
}
-
}
-
end
-
-
def default_form_settings
-
{
-
confirmation: {
-
redirectTo: 'samePage',
-
messageToShow: 'Thank you for your submission!',
-
samePageFormBehavior: 'hide_form'
-
},
-
restrictions: {},
-
layout: {
-
labelPlacement: 'top',
-
helpMessagePlacement: 'with_label',
-
errorMessagePlacement: 'inline'
-
}
-
}
-
end
-
-
def default_appearance_settings
-
{
-
theme: 'default',
-
customCss: '',
-
submitButtonPosition: 'left'
-
}
-
end
-
-
def field_types
-
[
-
{ type: 'input_text', label: 'Text Input', icon: 'text' },
-
{ type: 'input_email', label: 'Email', icon: 'envelope' },
-
{ type: 'input_number', label: 'Number', icon: 'hashtag' },
-
{ type: 'input_phone', label: 'Phone', icon: 'phone' },
-
{ type: 'textarea', label: 'Textarea', icon: 'align-left' },
-
{ type: 'select', label: 'Dropdown', icon: 'caret-down' },
-
{ type: 'input_radio', label: 'Radio Button', icon: 'dot-circle' },
-
{ type: 'input_checkbox', label: 'Checkbox', icon: 'check-square' },
-
{ type: 'input_date', label: 'Date', icon: 'calendar' },
-
{ type: 'input_file', label: 'File Upload', icon: 'upload' },
-
{ type: 'input_hidden', label: 'Hidden Field', icon: 'eye-slash' },
-
{ type: 'input_password', label: 'Password', icon: 'lock' },
-
{ type: 'input_url', label: 'Website URL', icon: 'link' },
-
{ type: 'rating', label: 'Rating', icon: 'star' },
-
{ type: 'slider', label: 'Slider', icon: 'sliders-h' },
-
{ type: 'repeater', label: 'Repeater', icon: 'redo' },
-
{ type: 'step', label: 'Step', icon: 'shoe-prints' },
-
{ type: 'html', label: 'HTML', icon: 'code' },
-
{ type: 'section_break', label: 'Section Break', icon: 'minus' },
-
{ type: 'payment', label: 'Payment', icon: 'credit-card' }
-
]
-
end
-
-
def available_integrations
-
[
-
{ id: 'mailchimp', name: 'Mailchimp', description: 'Email marketing', icon: 'mailchimp' },
-
{ id: 'slack', name: 'Slack', description: 'Team messaging', icon: 'slack' },
-
{ id: 'zapier', name: 'Zapier', description: 'Connect to 3000+ apps', icon: 'zapier' },
-
{ id: 'webhook', name: 'Webhooks', description: 'Custom webhooks', icon: 'link' },
-
{ id: 'google_sheets', name: 'Google Sheets', description: 'Spreadsheet integration', icon: 'table' },
-
{ id: 'stripe', name: 'Stripe', description: 'Payment processing', icon: 'stripe' },
-
{ id: 'paypal', name: 'PayPal', description: 'Payment processing', icon: 'paypal' }
-
]
-
end
-
-
def get_active_integrations
-
# Return list of active integrations
-
[]
-
end
-
end
-
-
-
class Admin::StorageProvidersController < Admin::BaseController
-
before_action :set_storage_provider, only: %i[show edit update destroy toggle]
-
-
# GET /admin/storage_providers
-
def index
-
@storage_providers = StorageProvider.ordered
-
end
-
-
# GET /admin/storage_providers/1
-
def show
-
end
-
-
# GET /admin/storage_providers/new
-
def new
-
@storage_provider = StorageProvider.new
-
end
-
-
# GET /admin/storage_providers/1/edit
-
def edit
-
end
-
-
# POST /admin/storage_providers
-
def create
-
@storage_provider = StorageProvider.new(storage_provider_params)
-
-
respond_to do |format|
-
if @storage_provider.save
-
format.html { redirect_to admin_storage_providers_path, notice: "Storage provider was successfully created." }
-
format.json { render :show, status: :created, location: @storage_provider }
-
else
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @storage_provider.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/storage_providers/1
-
def update
-
respond_to do |format|
-
if @storage_provider.update(storage_provider_params)
-
format.html { redirect_to admin_storage_providers_path, notice: "Storage provider was successfully updated." }
-
format.json { render :show, status: :ok, location: @storage_provider }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @storage_provider.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/storage_providers/1
-
def destroy
-
@storage_provider.destroy!
-
-
respond_to do |format|
-
format.html { redirect_to admin_storage_providers_path, notice: "Storage provider was successfully destroyed." }
-
format.json { head :no_content }
-
end
-
end
-
-
# PATCH /admin/storage_providers/1/toggle
-
def toggle
-
@storage_provider.update!(active: !@storage_provider.active)
-
-
respond_to do |format|
-
format.html { redirect_to admin_storage_providers_path, notice: "Storage provider status updated." }
-
format.json { render json: { active: @storage_provider.active } }
-
end
-
end
-
-
private
-
-
def set_storage_provider
-
@storage_provider = StorageProvider.find(params[:id])
-
end
-
-
def storage_provider_params
-
params.require(:storage_provider).permit(
-
:name,
-
:provider_type,
-
:active,
-
:position,
-
config: [
-
:local_path,
-
:access_key_id,
-
:secret_access_key,
-
:region,
-
:bucket,
-
:endpoint,
-
:project,
-
:credentials,
-
:storage_account_name,
-
:storage_access_key,
-
:container
-
]
-
)
-
end
-
end
-
-
class Admin::SubscribersController < Admin::BaseController
-
before_action :set_subscriber, only: [:show, :edit, :update, :destroy, :confirm, :unsubscribe]
-
-
# GET /admin/subscribers
-
def index
-
@subscribers = Subscriber.includes(:versions).recent
-
-
# Filter by status
-
@subscribers = @subscribers.where(status: params[:status]) if params[:status].present?
-
-
# Filter by source
-
@subscribers = @subscribers.by_source(params[:source]) if params[:source].present?
-
-
# Filter by tag
-
@subscribers = @subscribers.by_tag(params[:tag]) if params[:tag].present?
-
-
# Filter by list
-
@subscribers = @subscribers.by_list(params[:list]) if params[:list].present?
-
-
# Search
-
@subscribers = @subscribers.search(params[:q]) if params[:q].present?
-
-
# Get stats
-
@stats = Subscriber.stats
-
-
# For Tabulator (AJAX)
-
respond_to do |format|
-
format.html
-
format.json do
-
render json: {
-
data: @subscribers.limit(params[:size] || 20).offset(params[:page].to_i * (params[:size] || 20).to_i).map { |s| subscriber_json(s) },
-
last_page: @subscribers.count / (params[:size] || 20).to_i
-
}
-
end
-
end
-
end
-
-
# GET /admin/subscribers/:id
-
def show
-
end
-
-
# GET /admin/subscribers/new
-
def new
-
@subscriber = Subscriber.new
-
end
-
-
# GET /admin/subscribers/:id/edit
-
def edit
-
end
-
-
# POST /admin/subscribers
-
def create
-
@subscriber = Subscriber.new(subscriber_params)
-
@subscriber.status = 'confirmed' # Manual adds are auto-confirmed
-
@subscriber.confirmed_at = Time.current
-
@subscriber.source = 'admin'
-
-
if @subscriber.save
-
redirect_to admin_subscribers_path, notice: 'Subscriber added successfully.'
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /admin/subscribers/:id
-
def update
-
if @subscriber.update(subscriber_params)
-
redirect_to admin_subscribers_path, notice: 'Subscriber updated successfully.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/subscribers/:id
-
def destroy
-
@subscriber.destroy
-
redirect_to admin_subscribers_path, notice: 'Subscriber deleted successfully.'
-
end
-
-
# PATCH /admin/subscribers/:id/confirm
-
def confirm
-
@subscriber.confirm!
-
redirect_to admin_subscribers_path, notice: 'Subscriber confirmed.'
-
end
-
-
# PATCH /admin/subscribers/:id/unsubscribe
-
def unsubscribe
-
@subscriber.unsubscribe!
-
redirect_to admin_subscribers_path, notice: 'Subscriber unsubscribed.'
-
end
-
-
# POST /admin/subscribers/bulk_action
-
def bulk_action
-
subscriber_ids = params[:subscriber_ids] || []
-
action = params[:bulk_action]
-
-
case action
-
when 'confirm'
-
Subscriber.where(id: subscriber_ids).each(&:confirm!)
-
message = "#{subscriber_ids.count} subscribers confirmed."
-
when 'unsubscribe'
-
Subscriber.where(id: subscriber_ids).each(&:unsubscribe!)
-
message = "#{subscriber_ids.count} subscribers unsubscribed."
-
when 'delete'
-
Subscriber.where(id: subscriber_ids).destroy_all
-
message = "#{subscriber_ids.count} subscribers deleted."
-
when 'add_tag'
-
tag = params[:tag_value]
-
Subscriber.where(id: subscriber_ids).each { |s| s.add_tag(tag) }
-
message = "Tag '#{tag}' added to #{subscriber_ids.count} subscribers."
-
when 'add_to_list'
-
list = params[:list_value]
-
Subscriber.where(id: subscriber_ids).each { |s| s.add_to_list(list) }
-
message = "#{subscriber_ids.count} subscribers added to list '#{list}'."
-
else
-
message = "Invalid action."
-
end
-
-
redirect_to admin_subscribers_path, notice: message
-
end
-
-
# GET /admin/subscribers/import
-
def import
-
end
-
-
# POST /admin/subscribers/do_import
-
def do_import
-
unless params[:file].present?
-
redirect_to import_admin_subscribers_path, alert: 'Please select a file to import.'
-
return
-
end
-
-
file = params[:file]
-
-
begin
-
result = Subscriber.import_from_csv(file.read)
-
-
if result[:errors].empty?
-
redirect_to admin_subscribers_path, notice: "Successfully imported #{result[:imported]} subscribers."
-
else
-
flash[:alert] = "Imported #{result[:imported]} of #{result[:total]} subscribers. #{result[:errors].count} errors occurred."
-
redirect_to admin_subscribers_path
-
end
-
rescue => e
-
redirect_to import_admin_subscribers_path, alert: "Import failed: #{e.message}"
-
end
-
end
-
-
# GET /admin/subscribers/export
-
def export
-
csv_data = Subscriber.to_csv
-
-
send_data csv_data,
-
filename: "subscribers-#{Date.today}.csv",
-
type: 'text/csv',
-
disposition: 'attachment'
-
end
-
-
# GET /admin/subscribers/stats
-
def stats
-
render json: Subscriber.stats
-
end
-
-
private
-
-
def set_subscriber
-
@subscriber = Subscriber.find(params[:id])
-
end
-
-
def subscriber_params
-
params.require(:subscriber).permit(
-
:email,
-
:name,
-
:status,
-
:source,
-
:notes,
-
tags: [],
-
lists: [],
-
metadata: {}
-
)
-
end
-
-
def subscriber_json(subscriber)
-
{
-
id: subscriber.id,
-
email: subscriber.email,
-
name: subscriber.name,
-
status: subscriber.status,
-
source: subscriber.source,
-
tags: subscriber.tags || [],
-
lists: subscriber.lists || [],
-
confirmed_at: subscriber.confirmed_at&.strftime('%Y-%m-%d %H:%M'),
-
created_at: subscriber.created_at.strftime('%Y-%m-%d %H:%M'),
-
actions: view_context.render(partial: 'admin/subscribers/actions', locals: { subscriber: subscriber })
-
}
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::System::ApiTokensController < Admin::BaseController
-
before_action :set_api_token, only: [:show, :edit, :update, :destroy, :toggle, :regenerate]
-
-
def index
-
@api_tokens = current_user.administrator? ? ApiToken.all.includes(:user) : current_user.api_tokens
-
@api_tokens = @api_tokens.recent.page(params[:page]).per(20)
-
end
-
-
def show
-
end
-
-
def new
-
@api_token = current_user.api_tokens.build(role: 'public')
-
end
-
-
def create
-
@api_token = current_user.api_tokens.build(api_token_params)
-
-
if @api_token.save
-
flash[:notice] = "API Token created successfully. Token: #{@api_token.token}"
-
redirect_to admin_system_api_token_path(@api_token)
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
def edit
-
end
-
-
def update
-
if @api_token.update(api_token_params)
-
flash[:notice] = "API Token updated successfully."
-
redirect_to admin_system_api_token_path(@api_token)
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
def destroy
-
@api_token.destroy
-
flash[:notice] = "API Token deleted successfully."
-
redirect_to admin_system_api_tokens_path
-
end
-
-
def toggle
-
@api_token.update!(active: !@api_token.active)
-
flash[:notice] = "API Token #{@api_token.active ? 'activated' : 'deactivated'}."
-
redirect_to admin_system_api_tokens_path
-
end
-
-
def regenerate
-
new_token = SecureRandom.base58(32)
-
@api_token.update!(token: new_token)
-
flash[:notice] = "API Token regenerated. New token: #{new_token}"
-
redirect_to admin_system_api_token_path(@api_token)
-
end
-
-
private
-
-
def set_api_token
-
@api_token = if current_user.administrator?
-
ApiToken.find(params[:id])
-
else
-
current_user.api_tokens.find(params[:id])
-
end
-
end
-
-
def api_token_params
-
permitted = [:name, :role, :expires_at, :active]
-
-
# Only admins can set custom permissions
-
permitted << :permissions if current_user.administrator?
-
-
params.require(:api_token).permit(permitted)
-
end
-
end
-
-
-
-
-
-
module Admin
-
module System
-
class ChannelsController < Admin::BaseController
-
before_action :set_channel, only: [:show, :edit, :update, :destroy]
-
-
def index
-
@channels = Channel.all.order(:name)
-
-
respond_to do |format|
-
format.html do
-
@channels_data = channels_json
-
@stats = {
-
total: Channel.count,
-
active: Channel.active.count,
-
overrides: ChannelOverride.count,
-
content_items: Post.count + Page.count + Medium.count
-
}
-
@bulk_actions = [
-
{ value: 'enable', label: 'Enable Channels' },
-
{ value: 'disable', label: 'Disable Channels' },
-
{ value: 'delete', label: 'Delete Channels' }
-
]
-
@status_options = [
-
{ value: 'enabled', label: 'Enabled' },
-
{ value: 'disabled', label: 'Disabled' }
-
]
-
@columns = [
-
{
-
title: "",
-
formatter: "rowSelection",
-
titleFormatter: "rowSelection",
-
width: 40,
-
headerSort: false
-
},
-
{
-
title: "Name",
-
field: "name",
-
width: 200,
-
formatter: "html"
-
},
-
{
-
title: "Slug",
-
field: "slug",
-
width: 120
-
},
-
{
-
title: "Domain",
-
field: "domain",
-
width: 150
-
},
-
{
-
title: "Locale",
-
field: "locale",
-
width: 80
-
},
-
{
-
title: "Status",
-
field: "status",
-
width: 100,
-
formatter: "html"
-
},
-
{
-
title: "Content",
-
field: "content_counts",
-
width: 150,
-
formatter: "html"
-
},
-
{
-
title: "Overrides",
-
field: "overrides_count",
-
width: 100
-
},
-
{
-
title: "Created",
-
field: "created_at",
-
width: 150,
-
formatter: "datetime",
-
formatterParams: {
-
inputFormat: "YYYY-MM-DDTHH:mm:ss.SSSZ",
-
outputFormat: "DD/MM/YYYY HH:mm"
-
}
-
},
-
{
-
title: "Actions",
-
field: "actions",
-
width: 120,
-
headerSort: false,
-
formatter: "html"
-
}
-
]
-
end
-
format.json { render json: channels_json }
-
end
-
end
-
-
def show
-
@overrides = @channel.channel_overrides.includes(:resource).order(:resource_type, :path)
-
end
-
-
def new
-
@channel = Channel.new
-
end
-
-
def create
-
@channel = Channel.new(channel_params)
-
-
if @channel.save
-
redirect_to admin_system_channel_path(@channel), notice: 'Channel was successfully created.'
-
else
-
render :new
-
end
-
end
-
-
def edit
-
end
-
-
def update
-
if @channel.update(channel_params)
-
redirect_to admin_system_channel_path(@channel), notice: 'Channel was successfully updated.'
-
else
-
render :edit
-
end
-
end
-
-
def destroy
-
@channel.destroy
-
redirect_to admin_system_channels_path, notice: 'Channel was successfully deleted.'
-
end
-
-
private
-
-
def set_channel
-
@channel = Channel.find(params[:id])
-
end
-
-
def channel_params
-
params.require(:channel).permit(:name, :slug, :domain, :locale, :enabled, metadata: {}, settings: {})
-
end
-
-
def channels_json
-
@channels.map do |channel|
-
{
-
id: channel.id,
-
name: "<a href='#{admin_system_channel_path(channel)}' class='text-indigo-400 hover:text-indigo-300 font-medium'>#{channel.name}</a>",
-
slug: channel.slug,
-
domain: channel.domain || '-',
-
locale: channel.locale.upcase,
-
status: channel.enabled? ?
-
"<span class='inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800'>Enabled</span>" :
-
"<span class='inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800'>Disabled</span>",
-
content_counts: "#{channel.posts.count} posts, #{channel.pages.count} pages, #{channel.media.count} media",
-
overrides_count: channel.channel_overrides.count,
-
created_at: channel.created_at.iso8601,
-
actions: "<div class='flex items-center space-x-2'>
-
<a href='#{admin_system_channel_path(channel)}' class='text-gray-400 hover:text-white' title='View'>
-
<svg class='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
-
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15 12a3 3 0 11-6 0 3 3 0 016 0z'/>
-
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z'/>
-
</svg>
-
</a>
-
<a href='#{edit_admin_system_channel_path(channel)}' class='text-gray-400 hover:text-white' title='Edit'>
-
<svg class='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
-
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z'/>
-
</svg>
-
</a>
-
<a href='#{admin_system_channel_channel_overrides_path(channel)}' class='text-gray-400 hover:text-white' title='Overrides'>
-
<svg class='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
-
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z'/>
-
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15 12a3 3 0 11-6 0 3 3 0 016 0z'/>
-
</svg>
-
</a>
-
</div>"
-
}
-
end
-
end
-
end
-
end
-
end
-
class Admin::System::HeadlessController < Admin::BaseController
-
def index
-
@headless_enabled = SiteSetting.get('headless_mode', false)
-
@cors_enabled = SiteSetting.get('cors_enabled', false)
-
@cors_origins = SiteSetting.get('cors_origins', '*')
-
@cors_methods = SiteSetting.get('cors_methods', 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
-
@cors_headers = SiteSetting.get('cors_headers', '*')
-
end
-
-
def update
-
headless_enabled = params[:headless_mode] == '1'
-
cors_enabled = params[:cors_enabled] == '1'
-
-
SiteSetting.set('headless_mode', headless_enabled)
-
SiteSetting.set('cors_enabled', cors_enabled)
-
SiteSetting.set('cors_origins', params[:cors_origins]) if params[:cors_origins].present?
-
SiteSetting.set('cors_methods', params[:cors_methods]) if params[:cors_methods].present?
-
SiteSetting.set('cors_headers', params[:cors_headers]) if params[:cors_headers].present?
-
-
if headless_enabled
-
flash[:notice] = "Headless mode enabled. Frontend routes are now disabled. Access your content via GraphQL and REST APIs."
-
else
-
flash[:notice] = "Headless mode disabled. Frontend routes are now enabled."
-
end
-
-
redirect_to admin_system_headless_path
-
end
-
-
def test_cors
-
render json: {
-
success: true,
-
message: "CORS is configured correctly",
-
cors_origins: SiteSetting.get('cors_origins', '*'),
-
timestamp: Time.current
-
}
-
end
-
end
-
-
-
-
-
-
class Admin::TagsController < Admin::BaseController
-
before_action :set_taxonomy
-
before_action :set_term, only: %i[ show edit update destroy ]
-
-
# GET /admin/tags or /admin/tags.json
-
def index
-
@terms = @taxonomy.terms.includes(:term_relationships).order(:name)
-
-
respond_to do |format|
-
format.html
-
format.json {
-
render json: @terms.map { |term|
-
{
-
id: term.id,
-
name: term.name,
-
slug: term.slug,
-
description: term.description,
-
posts_count: term.term_relationships.where(object_type: 'Post').count,
-
created_at: term.created_at.strftime('%B %d, %Y')
-
}
-
}
-
}
-
end
-
end
-
-
# GET /admin/tags/1 or /admin/tags/1.json
-
def show
-
@posts = Post.joins(:term_relationships)
-
.where(term_relationships: { term_id: @term.id })
-
.order(created_at: :desc)
-
.page(params[:page])
-
end
-
-
# GET /admin/tags/new
-
def new
-
@term = @taxonomy.terms.new
-
end
-
-
# GET /admin/tags/1/edit
-
def edit
-
end
-
-
# POST /admin/tags or /admin/tags.json
-
def create
-
@term = @taxonomy.terms.new(term_params)
-
-
respond_to do |format|
-
if @term.save
-
format.html { redirect_to admin_tag_path(@term), notice: "Tag was successfully created." }
-
format.json { render :show, status: :created, location: admin_tag_path(@term) }
-
else
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @term.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/tags/1 or /admin/tags/1.json
-
def update
-
respond_to do |format|
-
if @term.update(term_params)
-
format.html { redirect_to admin_tag_path(@term), notice: "Tag was successfully updated.", status: :see_other }
-
format.json { render :show, status: :ok, location: admin_tag_path(@term) }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @term.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/tags/1 or /admin/tags/1.json
-
def destroy
-
@term.destroy!
-
-
respond_to do |format|
-
format.html { redirect_to admin_tags_path, notice: "Tag was successfully deleted.", status: :see_other }
-
format.json { head :no_content }
-
end
-
end
-
-
private
-
# Set the tag taxonomy
-
def set_taxonomy
-
@taxonomy = Taxonomy.find_by!(slug: 'tag')
-
rescue ActiveRecord::RecordNotFound
-
redirect_to admin_taxonomies_path, alert: "Tag taxonomy not found. Please run seeds."
-
end
-
-
# Use callbacks to share common setup or constraints between actions.
-
def set_term
-
@term = @taxonomy.terms.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to admin_tags_path, alert: "Tag not found."
-
end
-
-
# Only allow a list of trusted parameters through.
-
def term_params
-
params.require(:term).permit(:name, :slug, :description, :meta)
-
end
-
end
-
class Admin::TaxonomiesController < Admin::BaseController
-
before_action :set_taxonomy, only: [:show, :edit, :update, :destroy]
-
-
# GET /admin/taxonomies
-
def index
-
@taxonomies = Taxonomy.all.order(created_at: :desc)
-
end
-
-
# GET /admin/taxonomies/:id
-
def show
-
@terms = @taxonomy.terms.includes(:parent, :children).order(name: :asc)
-
end
-
-
# GET /admin/taxonomies/new
-
def new
-
@taxonomy = Taxonomy.new
-
end
-
-
# GET /admin/taxonomies/:id/edit
-
def edit
-
end
-
-
# POST /admin/taxonomies
-
def create
-
@taxonomy = Taxonomy.new(taxonomy_params)
-
-
if @taxonomy.save
-
redirect_to admin_taxonomies_path, notice: 'Taxonomy was successfully created.'
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /admin/taxonomies/:id
-
def update
-
if @taxonomy.update(taxonomy_params)
-
redirect_to admin_taxonomy_path(@taxonomy), notice: 'Taxonomy was successfully updated.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/taxonomies/:id
-
def destroy
-
@taxonomy.destroy
-
redirect_to admin_taxonomies_path, notice: 'Taxonomy was successfully deleted.'
-
end
-
-
private
-
-
def set_taxonomy
-
@taxonomy = Taxonomy.friendly.find(params[:id])
-
end
-
-
def taxonomy_params
-
params.require(:taxonomy).permit(:name, :slug, :description, :hierarchical, object_types: [], settings: {})
-
end
-
end
-
class Admin::TemplateCustomizerController < Admin::BaseController
-
layout :resolve_layout
-
before_action :load_current_theme, only: [:index, :customize, :load_template_content, :load_section_schema, :save_customization, :publish_customization, :test_data]
-
before_action :set_template_data, only: [:customize, :save_customization]
-
-
def index
-
# Redirect to customize action for the current theme
-
redirect_to admin_template_customizer_customize_path(template: 'index')
-
end
-
-
def customize
-
@template_type = params[:template] || 'index'
-
@template_data = load_template_data(@template_type)
-
@available_templates = get_available_templates
-
@theme_sections = load_theme_sections(@template_type)
-
@theme_settings = load_theme_settings
-
-
# Debug logging
-
Rails.logger.info "Theme sections loaded: #{@theme_sections.keys}"
-
Rails.logger.info "Theme sections JSON: #{@theme_sections.to_json}"
-
-
render layout: 'editor_fullscreen'
-
end
-
-
def test_data
-
@template_type = params[:template] || 'index'
-
@template_data = load_template_data(@template_type)
-
@available_templates = get_available_templates
-
@theme_sections = load_theme_sections(@template_type)
-
@theme_settings = load_theme_settings
-
-
render json: {
-
themeSections: @theme_sections.map { |k, v| [k, { 'type' => v['type'], 'name' => v['name'], 'schema' => v['schema'] }] }.to_h,
-
themeSettings: @theme_settings,
-
templateData: @template_data
-
}
-
end
-
-
def save_customization
-
template_type = params[:template_type]
-
template_data = JSON.parse(params[:template_data])
-
-
begin
-
# Create a preview theme version
-
theme_version = ThemeVersion.create_preview(
-
@current_theme,
-
current_user,
-
summary: "Customized #{template_type} template"
-
)
-
-
# Update the template in the theme version
-
service = ThemeVersionService.new(theme_version)
-
service.update_template(template_type, template_data)
-
-
respond_to do |format|
-
format.json { render json: { success: true, message: 'Preview saved successfully', version_id: theme_version.id } }
-
end
-
rescue => e
-
Rails.logger.error "Error saving customization: #{e.message}"
-
respond_to do |format|
-
format.json { render json: { success: false, errors: [e.message] }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
def publish_customization
-
template_type = params[:template_type]
-
template_data = JSON.parse(params[:template_data])
-
-
begin
-
# Create a live theme version
-
theme_version = ThemeVersion.create_live_version(
-
@current_theme,
-
current_user,
-
summary: "Published #{template_type} template"
-
)
-
-
# Update the template in the theme version
-
service = ThemeVersionService.new(theme_version)
-
service.update_template(template_type, template_data)
-
-
respond_to do |format|
-
format.json { render json: { success: true, message: 'Theme published successfully', version_id: theme_version.id } }
-
end
-
rescue => e
-
Rails.logger.error "Error publishing customization: #{e.message}"
-
respond_to do |format|
-
format.json { render json: { success: false, errors: [e.message] }, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
def load_template_content
-
template_type = params[:template_type] || 'index'
-
-
# Get the current live theme version or fallback to base theme files
-
live_version = ThemeVersion.live.for_theme(@current_theme).first
-
-
if live_version
-
# Use live version data
-
template_data = live_version.template_data(template_type)
-
sections_data = load_sections_from_version(live_version)
-
else
-
# Fallback to base theme files (read-only)
-
template_data = load_template_data(template_type)
-
sections_data = load_theme_sections(template_type)
-
end
-
-
# Render preview HTML using the theme version or base files
-
begin
-
preview_html = render_theme_preview(template_type, template_data, sections_data)
-
rescue => e
-
Rails.logger.error "Error rendering theme preview: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
preview_html = "<div style='padding: 20px; color: red;'>Error rendering preview: #{e.message}</div>"
-
end
-
-
render json: {
-
html: preview_html,
-
template_data: template_data,
-
sections: sections_data,
-
settings: load_theme_settings
-
}
-
end
-
-
def load_section_schema
-
section_type = params[:section_type]
-
schema = get_section_schema(section_type)
-
-
render json: { schema: schema }
-
end
-
-
private
-
-
def load_current_theme
-
@current_theme = Railspress::ThemeLoader.current_theme
-
@theme_config = Railspress::ThemeLoader.theme_config
-
@theme_path = Rails.root.join('app', 'themes', @current_theme)
-
end
-
-
def set_template_data
-
@template_type = params[:template] || 'index'
-
end
-
-
def load_template_data(template_type)
-
template_file = @theme_path.join('templates', "#{template_type}.json")
-
-
if File.exist?(template_file)
-
JSON.parse(File.read(template_file))
-
else
-
# Return default template structure
-
{
-
'sections' => {},
-
'order' => []
-
}
-
end
-
end
-
-
def get_available_templates
-
templates_dir = @theme_path.join('templates')
-
return [] unless Dir.exist?(templates_dir)
-
-
Dir.entries(templates_dir)
-
.select { |f| f.end_with?('.json') }
-
.map { |f| f.chomp('.json') }
-
.reject { |f| f == 'index' } # index is the default
-
.unshift('index') # Put index first
-
end
-
-
def load_theme_sections(template_type)
-
sections_dir = @theme_path.join('sections')
-
return {} unless Dir.exist?(sections_dir)
-
-
sections = {}
-
Dir.entries(sections_dir).each do |file|
-
next unless file.end_with?('.liquid')
-
-
section_type = file.chomp('.liquid')
-
section_file = sections_dir.join(file)
-
-
begin
-
content = File.read(section_file)
-
-
# Extract schema from liquid file
-
schema_match = content.match(/\{%\s*schema\s*%\}(.*?)\{%\s*endschema\s*%\}/m)
-
-
schema = {}
-
if schema_match
-
begin
-
schema = JSON.parse(schema_match[1])
-
rescue JSON::ParserError => e
-
Rails.logger.warn "Failed to parse schema for #{section_type}: #{e.message}"
-
schema = {}
-
end
-
end
-
-
sections[section_type] = {
-
'type' => section_type,
-
'name' => schema['name'] || section_type.humanize,
-
'schema' => schema,
-
'content' => content
-
}
-
rescue => e
-
Rails.logger.error "Error loading section #{section_type}: #{e.message}"
-
# Still add the section with basic info
-
sections[section_type] = {
-
'type' => section_type,
-
'name' => section_type.humanize,
-
'schema' => {},
-
'content' => ''
-
}
-
end
-
end
-
-
sections
-
end
-
-
def load_theme_settings
-
settings_file = @theme_path.join('config', 'settings_schema.json')
-
-
if File.exist?(settings_file)
-
JSON.parse(File.read(settings_file))
-
else
-
[]
-
end
-
end
-
-
def get_section_schema(section_type)
-
section_file = @theme_path.join('sections', "#{section_type}.liquid")
-
-
if File.exist?(section_file)
-
content = File.read(section_file)
-
schema_match = content.match(/\{%\s*schema\s*%\}(.*?)\{%\s*endschema\s*%\}/m)
-
schema_match ? JSON.parse(schema_match[1]) : {}
-
else
-
{}
-
end
-
end
-
-
def render_liquid_template(template_type, template_data)
-
renderer = LiquidTemplateRenderer.new(@current_theme, template_type, template_data)
-
renderer.render
-
end
-
-
def create_theme_version(template_type, template_data)
-
# Create a new theme file version for the template
-
ThemeFileVersion.create!(
-
theme_name: @current_theme,
-
file_path: "templates/#{template_type}.json",
-
content: template_data.to_json,
-
file_size: template_data.to_json.bytesize,
-
user_id: current_user&.id,
-
change_summary: "Updated #{template_type} template via customizer"
-
)
-
end
-
-
def load_sections_from_version(theme_version)
-
sections_data = {}
-
-
theme_version.sections.includes(:theme_file).each do |file_version|
-
section_type = file_version.file_path.gsub('sections/', '').gsub('.liquid', '')
-
sections_data[section_type] = {
-
'type' => section_type,
-
'schema' => file_version.theme_file&.parsed_schema || {},
-
'content' => file_version.content
-
}
-
end
-
-
sections_data
-
end
-
-
def render_theme_preview(template_type, template_data, sections_data)
-
begin
-
# If we have template data with sections, render them
-
if template_data && template_data['order'] && template_data['sections']
-
return render_sections_from_template_data(template_data, template_type)
-
else
-
# Fallback to basic template structure
-
return render_basic_template(template_type)
-
end
-
rescue => e
-
Rails.logger.error "Error in render_theme_preview: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
return "<div style='padding: 20px; color: red;'>Error rendering preview: #{e.message}</div>"
-
end
-
end
-
-
def render_sections_from_template_data(template_data, template_type)
-
sections_html = ''
-
-
template_data['order'].each do |section_id|
-
section_data = template_data['sections'][section_id]
-
next unless section_data
-
-
section_type = section_data['type']
-
-
# For now, just use fallback HTML to avoid Liquid parsing issues
-
# TODO: Implement proper Liquid parsing with Shopify tag support
-
sections_html += generate_fallback_section_html(section_type, section_data)
-
end
-
-
# Wrap in basic HTML structure
-
wrap_in_html_template(sections_html, template_type)
-
end
-
-
def render_basic_template(template_type)
-
# Return a basic HTML structure for the template type
-
case template_type
-
when 'index'
-
content = '<section class="hero-section"><h1>Welcome to our site</h1><p>This is the homepage content.</p></section>'
-
when 'blog'
-
content = '<section class="blog-section"><h1>Blog</h1><p>Latest blog posts will appear here.</p></section>'
-
when 'page'
-
content = '<section class="page-section"><h1>Page Title</h1><p>Page content goes here.</p></section>'
-
when 'post'
-
content = '<section class="post-section"><h1>Blog Post Title</h1><p>Blog post content goes here.</p></section>'
-
else
-
content = "<section class=\"#{template_type}-section\"><h1>#{template_type.humanize}</h1><p>Content for #{template_type} page.</p></section>"
-
end
-
-
wrap_in_html_template(content, template_type)
-
end
-
-
def wrap_in_html_template(content, template_type)
-
<<~HTML
-
<!DOCTYPE html>
-
<html>
-
<head>
-
<meta charset="utf-8">
-
<title>#{template_type.humanize} - Preview</title>
-
<style>
-
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
-
.hero-section { background: #f0f0f0; padding: 40px; text-align: center; }
-
.blog-section, .page-section, .post-section { padding: 20px; }
-
</style>
-
</head>
-
<body>
-
#{content}
-
</body>
-
</html>
-
HTML
-
end
-
-
def generate_fallback_section_html(section_type, section_data)
-
section_name = section_data['name'] || section_type.humanize
-
<<~HTML
-
<section class="#{section_type}-section" data-section-type="#{section_type}">
-
<div class="section-content">
-
<h2>#{section_name}</h2>
-
<p>This #{section_name.downcase} section will be loaded from theme files.</p>
-
</div>
-
</section>
-
HTML
-
end
-
-
def load_section_content(section_type)
-
section_file = @theme_path.join('sections', "#{section_type}.liquid")
-
-
if File.exist?(section_file)
-
File.read(section_file)
-
else
-
nil
-
end
-
end
-
-
def load_page_data(template_type)
-
case template_type
-
when 'index'
-
{ 'title' => 'Homepage', 'description' => 'Welcome to our site' }
-
when 'blog'
-
{ 'title' => 'Blog', 'description' => 'Latest posts' }
-
when 'page'
-
{ 'title' => 'Page', 'description' => 'Page content' }
-
when 'post'
-
{ 'title' => 'Blog Post', 'description' => 'Post content' }
-
else
-
{ 'title' => template_type.humanize, 'description' => '' }
-
end
-
end
-
-
def resolve_layout
-
action_name == 'customize' ? 'editor_fullscreen' : 'admin'
-
end
-
end
-
class Admin::TermsController < Admin::BaseController
-
before_action :set_taxonomy
-
before_action :set_term, only: [:edit, :update, :destroy]
-
-
# GET /admin/taxonomies/:taxonomy_id/terms
-
def index
-
@terms = @taxonomy.terms.includes(:parent, :children).order(name: :asc)
-
@term = Term.new(taxonomy: @taxonomy)
-
end
-
-
# POST /admin/taxonomies/:taxonomy_id/terms
-
def create
-
@term = @taxonomy.terms.build(term_params)
-
-
if @term.save
-
redirect_to admin_taxonomy_terms_path(@taxonomy), notice: 'Term was successfully created.'
-
else
-
@terms = @taxonomy.terms.includes(:parent, :children).order(name: :asc)
-
render :index, status: :unprocessable_entity
-
end
-
end
-
-
# GET /admin/taxonomies/:taxonomy_id/terms/:id/edit
-
def edit
-
end
-
-
# PATCH/PUT /admin/taxonomies/:taxonomy_id/terms/:id
-
def update
-
if @term.update(term_params)
-
redirect_to admin_taxonomy_terms_path(@taxonomy), notice: 'Term was successfully updated.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/taxonomies/:taxonomy_id/terms/:id
-
def destroy
-
@term.destroy
-
redirect_to admin_taxonomy_terms_path(@taxonomy), notice: 'Term was successfully deleted.'
-
end
-
-
private
-
-
def set_taxonomy
-
@taxonomy = Taxonomy.friendly.find(params[:taxonomy_id])
-
end
-
-
def set_term
-
@term = @taxonomy.terms.friendly.find(params[:id])
-
end
-
-
def term_params
-
params.require(:term).permit(:name, :slug, :description, :parent_id, metadata: {})
-
end
-
end
-
class Admin::ThemeEditorController < Admin::BaseController
-
layout :resolve_layout
-
before_action :set_themes_manager
-
before_action :set_active_theme
-
before_action :set_current_file, only: [:edit, :update, :destroy, :download, :versions, :restore]
-
-
def index
-
@file_tree = @themes_manager.file_tree(@active_theme.name)
-
@current_file_path = params[:file]
-
-
if @current_file_path
-
@file_content = @themes_manager.get_file(@current_file_path)
-
@file_versions = get_file_versions(@current_file_path)
-
end
-
-
render layout: 'editor'
-
end
-
-
def edit
-
respond_to do |format|
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.replace(
-
'file-editor',
-
partial: 'admin/theme_editor/editor',
-
locals: { file_path: @current_file_path, content: @file_content, versions: @file_versions }
-
)
-
end
-
format.html { redirect_to admin_theme_editor_index_path(file: @current_file_path) }
-
end
-
end
-
-
def update
-
# Get the theme file and create new version
-
theme_file = ThemeFile.find_by(theme_name: @active_theme.name, file_path: @current_file_path)
-
-
if theme_file
-
version = @themes_manager.create_file_version(theme_file, file_params[:content], current_user)
-
-
respond_to do |format|
-
format.turbo_stream do
-
render turbo_stream: [
-
turbo_stream.replace('flash-messages', partial: 'admin/shared/flash', locals: {
-
notice: 'File saved successfully!'
-
}),
-
turbo_stream.replace('file-versions', partial: 'admin/theme_editor/versions', locals: {
-
versions: get_file_versions(@current_file_path)
-
})
-
]
-
end
-
format.json { render json: { success: true, message: 'File saved!' } }
-
format.html { redirect_to admin_theme_editor_index_path(file: @current_file_path), notice: 'File saved successfully!' }
-
end
-
else
-
respond_to do |format|
-
format.turbo_stream do
-
render turbo_stream: turbo_stream.replace('flash-messages', partial: 'admin/shared/flash', locals: {
-
alert: @manager.errors.join(', ')
-
})
-
end
-
format.json { render json: { success: false, errors: @manager.errors }, status: :unprocessable_entity }
-
format.html { redirect_to admin_theme_editor_index_path(file: @current_file_path), alert: @manager.errors.join(', ') }
-
end
-
end
-
end
-
-
def create
-
file_path = params[:file_path]
-
content = params[:content] || ''
-
-
if @themes_manager.create_file(file_path, content)
-
render json: { success: true, message: 'File created successfully!', file_path: file_path }
-
else
-
render json: { success: false, errors: @themes_manager.errors }, status: :unprocessable_entity
-
end
-
end
-
-
def destroy
-
if @themes_manager.delete_file(@current_file_path)
-
redirect_to admin_theme_editor_index_path, notice: 'File deleted successfully!'
-
else
-
redirect_to admin_theme_editor_index_path(file: @current_file_path), alert: @themes_manager.errors.join(', ')
-
end
-
end
-
-
def rename
-
old_path = params[:old_path]
-
new_path = params[:new_path]
-
-
if @themes_manager.rename_file(old_path, new_path)
-
render json: { success: true, message: 'File renamed successfully!', new_path: new_path }
-
else
-
render json: { success: false, errors: @themes_manager.errors }, status: :unprocessable_entity
-
end
-
end
-
-
def download
-
full_path = File.join(@themes_manager.themes_path, @active_theme.name, @current_file_path)
-
-
if File.exist?(full_path)
-
send_file full_path, filename: File.basename(@current_file_path)
-
else
-
redirect_to admin_theme_editor_index_path, alert: 'File not found'
-
end
-
end
-
-
def search
-
query = params[:query]
-
results = @themes_manager.search(query)
-
-
render json: { results: results, count: results.size }
-
end
-
-
def versions
-
@versions = @themes_manager.file_versions(@current_file_path)
-
-
respond_to do |format|
-
format.html
-
format.json { render json: @versions }
-
end
-
end
-
-
def restore
-
version_id = params[:version_id]
-
-
if @themes_manager.restore_version(version_id)
-
redirect_to admin_theme_editor_index_path(file: @current_file_path), notice: 'Version restored successfully!'
-
else
-
redirect_to admin_theme_editor_index_path(file: @current_file_path), alert: @themes_manager.errors.join(', ')
-
end
-
end
-
-
def preview
-
# Render preview iframe
-
render layout: false
-
end
-
-
def open_file
-
file_path = params[:file]
-
-
if file_path.present?
-
@current_file_path = file_path
-
@file_content = @themes_manager.read_file(@current_file_path)
-
@file_versions = @themes_manager.file_versions(@current_file_path)
-
-
if @file_content.nil?
-
redirect_to admin_theme_editor_index_path, alert: @themes_manager.errors.join(', ')
-
return
-
end
-
-
respond_to do |format|
-
format.turbo_stream do
-
render turbo_stream: [
-
turbo_stream.replace('file-editor', partial: 'admin/theme_editor/editor', locals: {
-
file_path: @current_file_path,
-
content: @file_content,
-
versions: @file_versions
-
})
-
]
-
end
-
format.html { redirect_to admin_theme_editor_index_path(file: @current_file_path) }
-
end
-
else
-
redirect_to admin_theme_editor_index_path, alert: 'No file specified'
-
end
-
end
-
-
def test
-
render layout: false
-
end
-
-
private
-
-
def set_themes_manager
-
@themes_manager = ThemesManager.new
-
end
-
-
def set_active_theme
-
@active_theme = Theme.active.first
-
redirect_to admin_themes_path, alert: 'No active theme found. Please activate a theme first.' unless @active_theme
-
end
-
-
def set_current_file
-
@current_file_path = params[:file] || params[:id]
-
@file_content = @themes_manager.get_file(@current_file_path)
-
@file_versions = get_file_versions(@current_file_path)
-
-
if @file_content.nil?
-
redirect_to admin_theme_editor_index_path, alert: 'File not found or could not be read.'
-
end
-
end
-
-
def get_file_versions(file_path)
-
theme_file = ThemeFile.find_by(theme_name: @active_theme.name, file_path: file_path)
-
return [] unless theme_file
-
-
theme_file.theme_file_versions.order(version_number: :desc)
-
end
-
-
def file_params
-
params.require(:file).permit(:content, :change_summary)
-
end
-
-
def resolve_layout
-
action_name == 'index' ? 'editor' : 'admin'
-
end
-
end
-
-
class Admin::ThemesController < Admin::BaseController
-
before_action :ensure_admin, only: [:activate, :destroy, :sync]
-
before_action :set_themes_manager
-
-
# GET /admin/themes
-
def index
-
# Auto-sync themes from filesystem if none exist or if filesystem has themes not in database
-
filesystem_themes = Dir.glob(File.join(Rails.root, 'app', 'themes', '*')).map { |dir| File.basename(dir) }
-
database_themes = Theme.pluck(:slug)
-
-
if Theme.count == 0 || filesystem_themes.any? { |theme| !database_themes.include?(theme) }
-
Rails.logger.info "Auto-syncing themes from filesystem..."
-
@themes_manager.sync_themes
-
end
-
-
@active_theme = Theme.active.first
-
@installed_themes = Theme.all.order(:name)
-
-
# Convert Theme objects to hash structure expected by the view
-
@available_themes = @installed_themes.map do |theme|
-
{
-
id: theme.id,
-
name: theme.name,
-
display_name: theme.name,
-
description: theme.description || "No description available",
-
author: theme.author || "Unknown",
-
version: theme.version || "1.0.0",
-
active: theme.active
-
}
-
end
-
end
-
-
# GET /admin/themes/1
-
def show
-
@theme = Theme.find(params[:id])
-
end
-
-
# GET /admin/themes/new
-
def new
-
@theme = Theme.new
-
end
-
-
# GET /admin/themes/1/edit
-
def edit
-
@theme = Theme.find(params[:id])
-
end
-
-
# POST /admin/themes
-
def create
-
@theme = Theme.new(theme_params)
-
-
respond_to do |format|
-
if @theme.save
-
format.html { redirect_to admin_themes_path, notice: "Theme was successfully created." }
-
format.json { render :show, status: :created, location: @theme }
-
else
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @theme.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/themes/1
-
def update
-
@theme = Theme.find(params[:id])
-
-
respond_to do |format|
-
if @theme.update(theme_params)
-
format.html { redirect_to admin_themes_path, notice: "Theme was successfully updated." }
-
format.json { render :show, status: :ok, location: @theme }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @theme.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/themes/1
-
def destroy
-
@theme = Theme.find(params[:id])
-
-
if @theme.active?
-
redirect_to admin_themes_path, alert: "Cannot delete active theme."
-
else
-
@theme.destroy
-
redirect_to admin_themes_path, notice: "Theme was successfully deleted."
-
end
-
end
-
-
# PATCH /admin/themes/1/activate
-
def activate
-
@theme = Theme.find(params[:id])
-
-
if @theme.activate!
-
flash[:notice] = "✓ Theme '#{@theme.name}' activated successfully! View your frontend to see the changes."
-
else
-
flash[:alert] = "✗ Failed to activate theme '#{@theme.name}'. Please check the theme files."
-
end
-
-
redirect_to admin_themes_path
-
end
-
-
# POST /admin/themes/sync
-
def sync
-
synced_count = @themes_manager.sync_themes
-
-
if synced_count > 0
-
flash[:notice] = "✓ Synced #{synced_count} themes from filesystem to database."
-
else
-
flash[:info] = "All themes are already up to date."
-
end
-
-
redirect_to admin_themes_path
-
end
-
-
-
# GET /admin/themes/:id/load_customizer
-
def load_customizer
-
theme = Theme.find(params[:id])
-
-
# Only sync if no published version exists
-
unless theme.published_version
-
@themes_manager.sync_theme(theme.slug)
-
theme.reload
-
end
-
-
# Ensure published version exists
-
theme.ensure_published_version_exists!
-
-
# Find or create BuilderTheme
-
builder_theme = BuilderTheme.current_for_theme(theme.name.underscore)
-
-
if builder_theme
-
redirect_to admin_builder_path(builder_theme)
-
else
-
redirect_to admin_builder_index_path(theme_name: theme.name)
-
end
-
end
-
-
# GET /admin/themes/:id/load_preview
-
def load_preview
-
theme = Theme.find(params[:id])
-
-
# Only sync if no published version exists
-
unless theme.published_version
-
@themes_manager.sync_theme(theme.slug)
-
theme.reload
-
end
-
-
# Ensure published version exists
-
theme.ensure_published_version_exists!
-
-
# Redirect to preview
-
redirect_to preview_admin_themes_path(id: theme.id)
-
end
-
-
# GET /admin/themes/preview?id=theme_id
-
def preview
-
@theme_id = params[:id]
-
@theme = Theme.find(@theme_id)
-
@theme_name = @theme.name
-
@theme_config = load_theme_config(@theme_name)
-
-
# Ensure theme has a published version
-
@theme.ensure_published_version_exists!
-
published_version = @theme.published_version
-
-
# If still no published version, create one
-
unless published_version
-
@theme.ensure_published_version_exists!
-
published_version = @theme.published_version
-
end
-
-
if published_version
-
# Use FrontendRendererService for proper rendering
-
renderer = FrontendRendererService.new(published_version)
-
template_type = params[:template] || 'index'
-
-
begin
-
@preview_html = renderer.render_template(template_type, preview_context)
-
@assets = renderer.assets
-
rescue => e
-
Rails.logger.error "Theme preview rendering failed: #{e.message}"
-
@preview_html = "<div style='padding: 20px; color: red;'>Preview Error: #{e.message}</div>"
-
@assets = { css: '', js: '' }
-
end
-
else
-
@preview_html = "<div style='padding: 20px; color: red;'>No published version found for #{@theme_name}</div>"
-
@assets = { css: '', js: '' }
-
end
-
-
render 'preview', layout: false
-
end
-
-
private
-
-
def set_themes_manager
-
@themes_manager = ThemesManager.new
-
end
-
-
def theme_params
-
params.require(:theme).permit(:name, :description, :version, :active, :config)
-
end
-
-
def load_theme_config(theme_name)
-
theme = Theme.find_by(name: theme_name)
-
return {} unless theme
-
-
config_path = Rails.root.join('app', 'themes', theme.slug, 'config', 'theme.json')
-
if File.exist?(config_path)
-
JSON.parse(File.read(config_path))
-
else
-
{}
-
end
-
rescue JSON::ParserError
-
{}
-
end
-
-
def preview_context
-
{
-
# Page context
-
'page' => {
-
'title' => 'Theme Preview',
-
'description' => 'Preview of the theme',
-
'url' => '/preview',
-
'seo_title' => 'Theme Preview - RailsPress',
-
'meta_description' => 'Preview of the selected theme',
-
'template' => 'index'
-
},
-
-
# Site context
-
'site' => {
-
'title' => 'RailsPress Site',
-
'description' => 'A sample RailsPress site',
-
'url' => 'https://example.com',
-
'name' => 'RailsPress Site',
-
'tagline' => 'Built with Rails'
-
},
-
-
# Sample content
-
'posts' => [],
-
'pages' => [],
-
'current_user' => nil,
-
'settings' => {},
-
'theme_settings' => {}
-
}
-
end
-
end
-
class Admin::Tools::ErasePersonalDataController < Admin::BaseController
-
# GET /admin/tools/erase_personal_data
-
def index
-
@erasure_requests = PersonalDataErasureRequest.order(created_at: :desc).limit(50) rescue []
-
end
-
-
# POST /admin/tools/erase_personal_data/request
-
def request
-
email = params[:email]
-
reason = params[:reason]
-
-
unless email.present?
-
redirect_to admin_erase_personal_data_path, alert: 'Please provide an email address'
-
return
-
end
-
-
user = User.find_by(email: email)
-
-
unless user
-
redirect_to admin_erase_personal_data_path, alert: 'No user found with that email address'
-
return
-
end
-
-
# Prevent erasing admin users
-
if user.administrator?
-
redirect_to admin_erase_personal_data_path,
-
alert: 'Cannot erase data for administrator accounts. Please change their role first.'
-
return
-
end
-
-
# Create erasure request
-
erasure_request = PersonalDataErasureRequest.create!(
-
user_id: user.id,
-
email: email,
-
requested_by: current_user.id,
-
status: 'pending_confirmation',
-
reason: reason,
-
token: SecureRandom.hex(32),
-
metadata: {
-
user_posts_count: user.posts.count,
-
user_comments_count: Comment.where(email: email).count,
-
user_media_count: Medium.where(user_id: user.id).count rescue 0
-
}
-
)
-
-
redirect_to admin_erase_personal_data_path,
-
notice: "Erasure request created for #{email}. Awaiting final confirmation."
-
rescue => e
-
Rails.logger.error("Personal data erasure error: #{e.message}")
-
redirect_to admin_erase_personal_data_path, alert: "Request failed: #{e.message}"
-
end
-
-
# POST /admin/tools/erase_personal_data/confirm/:token
-
def confirm
-
erasure_request = PersonalDataErasureRequest.find_by(token: params[:token])
-
-
unless erasure_request
-
redirect_to admin_erase_personal_data_path, alert: 'Erasure request not found'
-
return
-
end
-
-
if erasure_request.status != 'pending_confirmation'
-
redirect_to admin_erase_personal_data_path, alert: 'This request has already been processed'
-
return
-
end
-
-
# Update status
-
erasure_request.update!(
-
status: 'processing',
-
confirmed_at: Time.current,
-
confirmed_by: current_user.id
-
)
-
-
# Queue the erasure job
-
PersonalDataErasureWorker.perform_async(erasure_request.id)
-
-
redirect_to admin_erase_personal_data_path,
-
notice: "Personal data erasure confirmed and queued for processing."
-
rescue => e
-
Rails.logger.error("Personal data erasure confirmation error: #{e.message}")
-
redirect_to admin_erase_personal_data_path, alert: "Confirmation failed: #{e.message}"
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::Tools::ExportController < Admin::BaseController
-
# GET /admin/tools/export
-
def index
-
@export_jobs = ExportJob.order(created_at: :desc).limit(20) rescue []
-
end
-
-
# POST /admin/tools/export/generate
-
def generate
-
export_type = params[:export_type] || 'json'
-
export_options = params[:options] || {}
-
-
# Create export job
-
export_job = ExportJob.create!(
-
export_type: export_type,
-
user_id: current_user.id,
-
status: 'pending',
-
options: export_options,
-
metadata: {
-
include_posts: export_options[:include_posts] == '1',
-
include_pages: export_options[:include_pages] == '1',
-
include_media: export_options[:include_media] == '1',
-
include_users: export_options[:include_users] == '1',
-
include_settings: export_options[:include_settings] == '1',
-
include_comments: export_options[:include_comments] == '1'
-
}
-
)
-
-
# Queue the export job
-
ExportWorker.perform_async(export_job.id)
-
-
redirect_to admin_export_path, notice: 'Export started. You will be able to download it shortly...'
-
rescue => e
-
Rails.logger.error("Export generation error: #{e.message}")
-
redirect_to admin_export_path, alert: "Export failed: #{e.message}"
-
end
-
-
# GET /admin/tools/export/download/:id
-
def download
-
export_job = ExportJob.find(params[:id])
-
-
unless export_job.status == 'completed'
-
redirect_to admin_export_path, alert: 'Export is not ready yet'
-
return
-
end
-
-
unless File.exist?(export_job.file_path)
-
redirect_to admin_export_path, alert: 'Export file not found'
-
return
-
end
-
-
send_file export_job.file_path,
-
filename: export_job.file_name,
-
type: export_job.content_type || 'application/octet-stream',
-
disposition: 'attachment'
-
rescue => e
-
Rails.logger.error("Export download error: #{e.message}")
-
redirect_to admin_export_path, alert: "Download failed: #{e.message}"
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::Tools::ExportPersonalDataController < Admin::BaseController
-
# GET /admin/tools/export_personal_data
-
def index
-
@export_requests = PersonalDataExportRequest.order(created_at: :desc).limit(50) rescue []
-
end
-
-
# POST /admin/tools/export_personal_data/request
-
def request
-
email = params[:email]
-
-
unless email.present?
-
redirect_to admin_export_personal_data_path, alert: 'Please provide an email address'
-
return
-
end
-
-
user = User.find_by(email: email)
-
-
unless user
-
redirect_to admin_export_personal_data_path, alert: 'No user found with that email address'
-
return
-
end
-
-
# Create export request
-
export_request = PersonalDataExportRequest.create!(
-
user_id: user.id,
-
email: email,
-
requested_by: current_user.id,
-
status: 'pending',
-
token: SecureRandom.hex(32)
-
)
-
-
# Queue the export job
-
PersonalDataExportWorker.perform_async(export_request.id)
-
-
redirect_to admin_export_personal_data_path,
-
notice: "Personal data export request created for #{email}. Processing..."
-
rescue => e
-
Rails.logger.error("Personal data export error: #{e.message}")
-
redirect_to admin_export_personal_data_path, alert: "Request failed: #{e.message}"
-
end
-
-
# GET /admin/tools/export_personal_data/download/:token
-
def download
-
export_request = PersonalDataExportRequest.find_by(token: params[:token])
-
-
unless export_request
-
redirect_to admin_export_personal_data_path, alert: 'Export request not found'
-
return
-
end
-
-
unless export_request.status == 'completed'
-
redirect_to admin_export_personal_data_path, alert: 'Export is not ready yet'
-
return
-
end
-
-
unless File.exist?(export_request.file_path)
-
redirect_to admin_export_personal_data_path, alert: 'Export file not found'
-
return
-
end
-
-
send_file export_request.file_path,
-
filename: "personal_data_#{export_request.email.gsub('@', '_at_')}_#{Date.today}.json",
-
type: 'application/json',
-
disposition: 'attachment'
-
rescue => e
-
Rails.logger.error("Personal data download error: #{e.message}")
-
redirect_to admin_export_personal_data_path, alert: "Download failed: #{e.message}"
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::Tools::ImportController < Admin::BaseController
-
# GET /admin/tools/import
-
def index
-
@import_jobs = ImportJob.order(created_at: :desc).limit(20) rescue []
-
end
-
-
# POST /admin/tools/import/upload
-
def upload
-
unless params[:file].present?
-
redirect_to admin_import_path, alert: 'Please select a file to import'
-
return
-
end
-
-
file = params[:file]
-
import_type = params[:import_type] || 'wordpress'
-
-
# Validate file type
-
allowed_extensions = case import_type
-
when 'wordpress' then ['.xml']
-
when 'json' then ['.json']
-
when 'csv' then ['.csv']
-
else ['.xml', '.json', '.csv']
-
end
-
-
file_ext = File.extname(file.original_filename).downcase
-
unless allowed_extensions.include?(file_ext)
-
redirect_to admin_import_path, alert: "Invalid file type. Allowed: #{allowed_extensions.join(', ')}"
-
return
-
end
-
-
# Store file temporarily
-
temp_file = Tempfile.new(['import', file_ext])
-
temp_file.binmode
-
temp_file.write(file.read)
-
temp_file.rewind
-
-
# Create import job
-
import_job = ImportJob.create!(
-
import_type: import_type,
-
file_path: temp_file.path,
-
file_name: file.original_filename,
-
user_id: current_user.id,
-
status: 'pending',
-
metadata: {
-
file_size: file.size,
-
content_type: file.content_type
-
}
-
)
-
-
# Queue the import job
-
ImportWorker.perform_async(import_job.id)
-
-
redirect_to admin_import_path, notice: 'Import started. This may take a few minutes...'
-
rescue => e
-
Rails.logger.error("Import upload error: #{e.message}")
-
redirect_to admin_import_path, alert: "Import failed: #{e.message}"
-
end
-
-
# POST /admin/tools/import/process
-
def process_import
-
import_job = ImportJob.find(params[:id])
-
-
if import_job.status == 'completed'
-
redirect_to admin_import_path, alert: 'This import has already been processed'
-
return
-
end
-
-
ImportWorker.perform_async(import_job.id)
-
-
redirect_to admin_import_path, notice: 'Import restarted'
-
end
-
end
-
-
-
-
-
-
-
-
class Admin::Tools::ShortcutsController < Admin::BaseController
-
before_action :set_shortcut, only: [:show, :edit, :update, :destroy, :toggle]
-
-
# GET /admin/tools/shortcuts
-
def index
-
@shortcuts = Shortcut.order(:category, :position)
-
-
respond_to do |format|
-
format.html
-
format.json {
-
render json: @shortcuts.active.order(:category, :position).map { |s| shortcut_json(s) }
-
}
-
end
-
end
-
-
# GET /admin/tools/shortcuts/:id
-
def show
-
end
-
-
# GET /admin/tools/shortcuts/new
-
def new
-
@shortcut = Shortcut.new
-
end
-
-
# GET /admin/tools/shortcuts/:id/edit
-
def edit
-
end
-
-
# POST /admin/tools/shortcuts
-
def create
-
@shortcut = Shortcut.new(shortcut_params)
-
-
if @shortcut.save
-
redirect_to admin_tools_shortcuts_path, notice: 'Shortcut created successfully.'
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /admin/tools/shortcuts/:id
-
def update
-
if @shortcut.update(shortcut_params)
-
redirect_to admin_tools_shortcuts_path, notice: 'Shortcut updated successfully.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/tools/shortcuts/:id
-
def destroy
-
@shortcut.destroy
-
redirect_to admin_tools_shortcuts_path, notice: 'Shortcut deleted successfully.'
-
end
-
-
# PATCH /admin/tools/shortcuts/:id/toggle
-
def toggle
-
@shortcut.update(active: !@shortcut.active)
-
redirect_to admin_tools_shortcuts_path, notice: "Shortcut #{@shortcut.active? ? 'enabled' : 'disabled'}."
-
end
-
-
# POST /admin/tools/shortcuts/reorder
-
def reorder
-
params[:order].each_with_index do |id, index|
-
Shortcut.find(id).update(position: index)
-
end
-
-
head :ok
-
end
-
-
private
-
-
def set_shortcut
-
@shortcut = Shortcut.find(params[:id])
-
end
-
-
def shortcut_params
-
params.require(:shortcut).permit(
-
:name, :description, :action_type, :action_value,
-
:icon, :category, :position, :active
-
)
-
end
-
-
def shortcut_json(shortcut)
-
{
-
id: shortcut.id,
-
name: shortcut.name,
-
description: shortcut.description,
-
action_type: shortcut.action_type,
-
action_value: shortcut.action_value,
-
icon: shortcut.icon,
-
category: shortcut.category
-
}
-
end
-
end
-
-
-
class Admin::Tools::SiteHealthController < Admin::BaseController
-
# GET /admin/tools/site_health
-
def index
-
@health_checks = run_health_checks
-
end
-
-
# POST /admin/tools/site_health/run_tests
-
def run_tests
-
@health_checks = run_health_checks
-
-
render json: {
-
status: overall_status(@health_checks),
-
checks: @health_checks,
-
timestamp: Time.current
-
}
-
end
-
-
private
-
-
def run_health_checks
-
checks = []
-
-
# Database Connection
-
checks << check_database
-
-
# Redis Connection
-
checks << check_redis
-
-
# File Permissions
-
checks << check_file_permissions
-
-
# Disk Space
-
checks << check_disk_space
-
-
# Ruby Version
-
checks << check_ruby_version
-
-
# Rails Version
-
checks << check_rails_version
-
-
# Required Gems
-
checks << check_required_gems
-
-
# ActiveStorage
-
checks << check_active_storage
-
-
# Mail Configuration
-
checks << check_mail_config
-
-
# Background Jobs
-
checks << check_sidekiq
-
-
# SSL/HTTPS
-
checks << check_ssl
-
-
# Performance
-
checks << check_caching
-
-
checks
-
end
-
-
def check_database
-
{
-
name: 'Database Connection',
-
category: 'critical',
-
status: ActiveRecord::Base.connection.active? ? 'pass' : 'fail',
-
message: ActiveRecord::Base.connection.active? ?
-
"Connected to #{ActiveRecord::Base.connection.adapter_name}" :
-
'Database connection failed',
-
details: {
-
adapter: ActiveRecord::Base.connection.adapter_name,
-
database: ActiveRecord::Base.connection.current_database
-
}
-
}
-
rescue => e
-
{ name: 'Database Connection', category: 'critical', status: 'fail', message: e.message }
-
end
-
-
def check_redis
-
{
-
name: 'Redis Connection',
-
category: 'recommended',
-
status: Redis.new.ping == 'PONG' ? 'pass' : 'fail',
-
message: Redis.new.ping == 'PONG' ? 'Redis is running' : 'Redis connection failed',
-
details: { url: ENV['REDIS_URL'] || 'redis://localhost:6379' }
-
}
-
rescue => e
-
{ name: 'Redis Connection', category: 'recommended', status: 'warning', message: "Redis not available: #{e.message}" }
-
end
-
-
def check_file_permissions
-
writable_paths = ['tmp', 'log', 'storage', 'public/uploads']
-
failed_paths = writable_paths.reject { |path| File.writable?(Rails.root.join(path)) }
-
-
{
-
name: 'File Permissions',
-
category: 'critical',
-
status: failed_paths.empty? ? 'pass' : 'fail',
-
message: failed_paths.empty? ?
-
'All required directories are writable' :
-
"Not writable: #{failed_paths.join(', ')}",
-
details: { checked_paths: writable_paths }
-
}
-
end
-
-
def check_disk_space
-
stat = Sys::Filesystem.stat(Rails.root.to_s)
-
free_gb = (stat.bytes_available / 1024.0 / 1024.0 / 1024.0).round(2)
-
-
{
-
name: 'Disk Space',
-
category: 'recommended',
-
status: free_gb > 1 ? 'pass' : 'warning',
-
message: "#{free_gb} GB available",
-
details: { free_gb: free_gb }
-
}
-
rescue => e
-
{ name: 'Disk Space', category: 'recommended', status: 'info', message: 'Could not check disk space' }
-
end
-
-
def check_ruby_version
-
required_version = Gem::Version.new('3.0.0')
-
current_version = Gem::Version.new(RUBY_VERSION)
-
-
{
-
name: 'Ruby Version',
-
category: 'critical',
-
status: current_version >= required_version ? 'pass' : 'fail',
-
message: "Ruby #{RUBY_VERSION}",
-
details: { required: '3.0.0+', current: RUBY_VERSION }
-
}
-
end
-
-
def check_rails_version
-
{
-
name: 'Rails Version',
-
category: 'info',
-
status: 'pass',
-
message: "Rails #{Rails.version}",
-
details: { version: Rails.version }
-
}
-
end
-
-
def check_required_gems
-
required_gems = %w[devise pundit acts_as_tenant sidekiq flipper]
-
missing = required_gems.reject { |gem| Gem.loaded_specs.key?(gem) }
-
-
{
-
name: 'Required Gems',
-
category: 'critical',
-
status: missing.empty? ? 'pass' : 'fail',
-
message: missing.empty? ?
-
'All required gems are installed' :
-
"Missing: #{missing.join(', ')}",
-
details: { required: required_gems, missing: missing }
-
}
-
end
-
-
def check_active_storage
-
configured = Rails.application.config.active_storage.service.present?
-
-
{
-
name: 'ActiveStorage',
-
category: 'recommended',
-
status: configured ? 'pass' : 'warning',
-
message: configured ?
-
"Service: #{Rails.application.config.active_storage.service}" :
-
'ActiveStorage not fully configured',
-
details: { service: Rails.application.config.active_storage.service }
-
}
-
end
-
-
def check_mail_config
-
configured = ActionMailer::Base.smtp_settings.present? ||
-
ActionMailer::Base.delivery_method != :smtp
-
-
{
-
name: 'Email Configuration',
-
category: 'recommended',
-
status: configured ? 'pass' : 'warning',
-
message: configured ?
-
"Delivery method: #{ActionMailer::Base.delivery_method}" :
-
'Email not configured',
-
details: { delivery_method: ActionMailer::Base.delivery_method }
-
}
-
end
-
-
def check_sidekiq
-
stats = Sidekiq::Stats.new
-
-
{
-
name: 'Background Jobs (Sidekiq)',
-
category: 'recommended',
-
status: 'pass',
-
message: "#{stats.workers_size} workers, #{stats.enqueued} jobs queued",
-
details: {
-
workers: stats.workers_size,
-
enqueued: stats.enqueued,
-
processed: stats.processed,
-
failed: stats.failed
-
}
-
}
-
rescue => e
-
{ name: 'Background Jobs (Sidekiq)', category: 'recommended', status: 'warning', message: 'Sidekiq not running' }
-
end
-
-
def check_ssl
-
{
-
name: 'HTTPS/SSL',
-
category: 'recommended',
-
status: request.ssl? ? 'pass' : 'warning',
-
message: request.ssl? ? 'HTTPS enabled' : 'Not using HTTPS',
-
details: { protocol: request.protocol }
-
}
-
end
-
-
def check_caching
-
enabled = Rails.application.config.action_controller.perform_caching
-
-
{
-
name: 'Caching',
-
category: 'performance',
-
status: enabled ? 'pass' : 'info',
-
message: enabled ? 'Caching enabled' : 'Caching disabled',
-
details: {
-
enabled: enabled,
-
store: Rails.cache.class.name
-
}
-
}
-
end
-
-
def overall_status(checks)
-
return 'fail' if checks.any? { |c| c[:category] == 'critical' && c[:status] == 'fail' }
-
return 'warning' if checks.any? { |c| c[:status] == 'warning' }
-
'pass'
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::TrashController < Admin::BaseController
-
before_action :ensure_admin
-
-
def index
-
@posts = Post.trashed.includes(:user, :content_type).order(deleted_at: :desc)
-
@pages = Page.trashed.includes(:user).order(deleted_at: :desc)
-
@media = Medium.trashed.includes(:user, :upload).order(deleted_at: :desc)
-
@comments = Comment.trashed.includes(:user, :commentable).order(deleted_at: :desc)
-
-
@stats = {
-
posts: @posts.count,
-
pages: @pages.count,
-
media: @media.count,
-
comments: @comments.count,
-
total: @posts.count + @pages.count + @media.count + @comments.count
-
}
-
end
-
-
def restore
-
item = find_item
-
item.untrash!
-
-
flash[:notice] = "#{item.class.name} restored successfully"
-
redirect_to admin_trash_index_path
-
end
-
-
def destroy_permanently
-
item = find_item
-
item.destroy_permanently!
-
-
flash[:notice] = "#{item.class.name} permanently deleted"
-
redirect_to admin_trash_index_path
-
end
-
-
def empty_trash
-
Post.trashed.find_each(&:destroy_permanently!)
-
Page.trashed.find_each(&:destroy_permanently!)
-
Medium.trashed.find_each(&:destroy_permanently!)
-
Comment.trashed.find_each(&:destroy_permanently!)
-
-
flash[:notice] = "Trash emptied successfully"
-
redirect_to admin_trash_index_path
-
end
-
-
private
-
-
def find_item
-
model_class = params[:type].constantize
-
model_class.find(params[:id])
-
end
-
end
-
class Admin::TrashSettingsController < Admin::BaseController
-
before_action :ensure_admin
-
before_action :set_trash_setting
-
-
def show
-
end
-
-
def update
-
if @trash_setting.update(trash_setting_params)
-
flash[:notice] = "Trash settings updated successfully"
-
redirect_to admin_trash_settings_path
-
else
-
flash[:alert] = "Failed to update trash settings"
-
render :show
-
end
-
end
-
-
def test_cleanup
-
# Test the cleanup process without actually deleting anything
-
threshold = @trash_setting.cleanup_threshold
-
-
@test_results = {
-
posts: Post.trashed.where('deleted_at < ?', threshold).count,
-
pages: Page.trashed.where('deleted_at < ?', threshold).count,
-
media: Medium.trashed.where('deleted_at < ?', threshold).count,
-
comments: Comment.trashed.where('deleted_at < ?', threshold).count
-
}
-
-
@test_results[:total] = @test_results.values.sum
-
-
render :show
-
end
-
-
def run_cleanup
-
if @trash_setting.auto_cleanup_enabled?
-
Post.cleanup_trash!
-
Page.cleanup_trash!
-
Medium.cleanup_trash!
-
Comment.cleanup_trash!
-
-
flash[:notice] = "Trash cleanup completed successfully"
-
else
-
flash[:alert] = "Automatic cleanup is disabled"
-
end
-
-
redirect_to admin_trash_settings_path
-
end
-
-
private
-
-
def set_trash_setting
-
@trash_setting = TrashSetting.current
-
end
-
-
def trash_setting_params
-
params.require(:trash_setting).permit(:auto_cleanup_enabled, :cleanup_after_days)
-
end
-
end
-
class Admin::UpdatesController < Admin::BaseController
-
def index
-
@update_info = Railspress::UpdateChecker.check_for_updates
-
@release_notes = Railspress::UpdateChecker.fetch_release_notes if @update_info[:update_available]
-
end
-
-
def check
-
# Force a fresh check (bypass cache)
-
Rails.cache.delete('railspress:update_check')
-
@update_info = Railspress::UpdateChecker.check_for_updates
-
-
if @update_info[:update_available]
-
flash[:success] = "New version available: #{@update_info[:latest_version]}"
-
else
-
flash[:info] = "You're running the latest version (#{@update_info[:current_version]})"
-
end
-
-
redirect_to admin_updates_path
-
end
-
-
def release_notes
-
@release_info = Railspress::UpdateChecker.fetch_release_notes
-
-
if @release_info
-
render json: @release_info
-
else
-
render json: { error: 'Could not fetch release notes' }, status: :unprocessable_entity
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::UserPreferencesController < Admin::BaseController
-
def show
-
render json: {
-
sidebar_order: current_user.sidebar_order
-
}
-
end
-
-
def update
-
if params[:sidebar_order].present?
-
current_user.update!(sidebar_order: params[:sidebar_order])
-
render json: { status: 'success' }
-
else
-
render json: { status: 'error', message: 'No sidebar order provided' }, status: :unprocessable_entity
-
end
-
end
-
end
-
class Admin::UsersController < Admin::BaseController
-
before_action :ensure_admin
-
before_action :set_user, only: [:show, :edit, :update, :destroy]
-
-
# GET /admin/users
-
def index
-
@users = User.all.order(created_at: :desc)
-
-
respond_to do |format|
-
format.html
-
format.json { render json: users_json }
-
end
-
end
-
-
# GET /admin/users/1
-
def show
-
end
-
-
# GET /admin/users/new
-
def new
-
@user = User.new
-
end
-
-
# GET /admin/users/1/edit
-
def edit
-
end
-
-
# POST /admin/users
-
def create
-
@user = User.new(user_params)
-
-
# Set password if provided
-
if params[:user][:password].present?
-
@user.password = params[:user][:password]
-
@user.password_confirmation = params[:user][:password_confirmation]
-
end
-
-
if @user.save
-
redirect_to admin_users_path, notice: 'User was successfully created.'
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /admin/users/1
-
def update
-
user_update_params = user_params
-
-
# Remove password params if not provided
-
if params[:user][:password].blank?
-
user_update_params = user_update_params.except(:password, :password_confirmation)
-
end
-
-
if @user.update(user_update_params)
-
redirect_to admin_users_path, notice: 'User was successfully updated.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/users/1
-
def destroy
-
if @user.id == current_user.id
-
redirect_to admin_users_path, alert: 'You cannot delete your own account.'
-
return
-
end
-
-
if @user.posts.any? || @user.pages.any?
-
redirect_to admin_users_path, alert: 'Cannot delete user with existing content. Reassign content first.'
-
return
-
end
-
-
@user.destroy
-
redirect_to admin_users_path, notice: 'User was successfully deleted.'
-
end
-
-
# PATCH /admin/users/update_monaco_theme
-
def update_monaco_theme
-
if current_user.update(monaco_theme: params[:monaco_theme])
-
render json: { success: true, theme: current_user.monaco_theme }
-
else
-
render json: { success: false, errors: current_user.errors.full_messages }
-
end
-
end
-
-
# POST /admin/users/regenerate_api_key
-
def regenerate_api_key
-
@user = User.find(params[:id])
-
@user.regenerate_api_token!
-
-
redirect_to admin_users_path, notice: "API key regenerated successfully for #{@user.name}"
-
end
-
-
# POST /admin/users/bulk_action
-
def bulk_action
-
action = params[:bulk_action]
-
user_ids = params[:user_ids] || []
-
-
case action
-
when 'delete'
-
bulk_delete(user_ids)
-
when 'activate'
-
bulk_activate(user_ids)
-
when 'deactivate'
-
bulk_deactivate(user_ids)
-
when 'change_role'
-
bulk_change_role(user_ids, params[:role])
-
else
-
redirect_to admin_users_path, alert: 'Invalid bulk action.'
-
end
-
end
-
-
# GET /admin/users/profile (current user profile)
-
def profile
-
@user = current_user
-
render :edit
-
end
-
-
# PATCH /admin/users/update_profile
-
def update_profile
-
@user = current_user
-
-
user_update_params = user_params.except(:role) # Can't change own role
-
-
# Remove password params if not provided
-
if params[:user][:password].blank?
-
user_update_params = user_update_params.except(:password, :password_confirmation)
-
end
-
-
if @user.update(user_update_params)
-
redirect_to admin_users_path, notice: 'Profile updated successfully.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def set_user
-
@user = User.find(params[:id])
-
end
-
-
def user_params
-
params.require(:user).permit(
-
:email,
-
:name,
-
:role,
-
:password,
-
:password_confirmation,
-
:avatar
-
)
-
end
-
-
def users_json
-
users = User.all.order(created_at: :desc)
-
-
# Apply filters
-
if params[:role].present?
-
users = users.where(role: params[:role])
-
end
-
-
if params[:search].present?
-
search_term = "%#{params[:search]}%"
-
users = users.where(
-
'email LIKE ? OR name LIKE ?',
-
search_term,
-
search_term
-
)
-
end
-
-
users.map do |user|
-
{
-
id: user.id,
-
email: user.email,
-
name: user.name || 'N/A',
-
role: user.role.titleize,
-
role_badge: role_badge(user.role),
-
posts_count: user.posts.count,
-
pages_count: user.pages.count,
-
created_at: user.created_at.strftime('%b %d, %Y'),
-
last_sign_in: user.last_sign_in_at&.strftime('%b %d, %Y %H:%M') || 'Never',
-
actions: user_actions(user)
-
}
-
end
-
end
-
-
def role_badge(role)
-
colors = {
-
'administrator' => 'bg-red-500',
-
'editor' => 'bg-blue-500',
-
'author' => 'bg-green-500',
-
'contributor' => 'bg-yellow-500',
-
'subscriber' => 'bg-gray-500'
-
}
-
-
color = colors[role] || 'bg-gray-500'
-
"<span class='px-2 py-1 #{color} text-white text-xs rounded-full'>#{role.titleize}</span>"
-
end
-
-
def user_actions(user)
-
actions = []
-
actions << { label: 'Edit', url: edit_admin_user_path(user), class: 'text-blue-600' }
-
actions << { label: 'View', url: admin_user_path(user), class: 'text-gray-600' }
-
actions << { label: 'Delete', url: admin_user_path(user), method: 'delete', class: 'text-red-600' } unless user.id == current_user.id
-
actions
-
end
-
-
def bulk_delete(user_ids)
-
# Don't delete current user
-
user_ids = user_ids.reject { |id| id.to_i == current_user.id }
-
-
# Don't delete users with content
-
users_with_content = User.where(id: user_ids).select { |u| u.posts.any? || u.pages.any? }
-
-
if users_with_content.any?
-
redirect_to admin_users_path, alert: "Cannot delete #{users_with_content.count} user(s) with existing content."
-
return
-
end
-
-
count = User.where(id: user_ids).destroy_all.count
-
redirect_to admin_users_path, notice: "#{count} user(s) deleted successfully."
-
end
-
-
def bulk_activate(user_ids)
-
# Implementation depends on if you have an 'active' field
-
redirect_to admin_users_path, notice: "Users activated."
-
end
-
-
def bulk_deactivate(user_ids)
-
# Implementation depends on if you have an 'active' field
-
redirect_to admin_users_path, notice: "Users deactivated."
-
end
-
-
def bulk_change_role(user_ids, new_role)
-
return unless User.roles.keys.include?(new_role)
-
-
# Don't change current user's role
-
user_ids = user_ids.reject { |id| id.to_i == current_user.id }
-
-
count = User.where(id: user_ids).update_all(role: new_role)
-
redirect_to admin_users_path, notice: "#{count} user(s) role changed to #{new_role.titleize}."
-
end
-
-
def ensure_admin
-
unless current_user&.administrator?
-
redirect_to admin_root_path, alert: 'Access denied. Administrator privileges required.'
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::WebhooksController < Admin::BaseController
-
before_action :set_webhook, only: [:show, :edit, :update, :destroy, :test, :toggle_active]
-
-
def index
-
@webhooks = Webhook.order(created_at: :desc)
-
-
# Filter by active status if specified
-
if params[:show_inactive] != 'true'
-
@webhooks = @webhooks.where(active: true)
-
end
-
-
@recent_deliveries = WebhookDelivery.includes(:webhook).recent.limit(20)
-
-
# Prepare data for Tabulator
-
@webhooks_data = webhooks_json
-
-
# Define columns for Tabulator
-
@columns = [
-
{
-
title: "",
-
formatter: "rowSelection",
-
titleFormatter: "rowSelection",
-
width: 40,
-
headerSort: false
-
},
-
{
-
title: "Name & URL",
-
field: "title",
-
width: 300,
-
formatter: "html"
-
},
-
{
-
title: "Events",
-
field: "events",
-
width: 200,
-
formatter: "html"
-
},
-
{
-
title: "Status",
-
field: "status",
-
width: 120,
-
formatter: "html"
-
},
-
{
-
title: "Stats",
-
field: "stats",
-
width: 120,
-
formatter: "html"
-
},
-
{
-
title: "Success Rate",
-
field: "success_rate",
-
width: 100,
-
formatter: "progress",
-
formatterParams: {
-
color: ["red", "orange", "green"],
-
min: 0,
-
max: 100
-
}
-
},
-
{
-
title: "Created",
-
field: "created_at",
-
width: 120,
-
formatter: "datetime",
-
formatterParams: {
-
inputFormat: "YYYY-MM-DDTHH:mm:ss.SSSZ",
-
outputFormat: "MMM DD, YYYY"
-
}
-
},
-
{
-
title: "Actions",
-
field: "actions",
-
width: 200,
-
formatter: "html",
-
headerSort: false
-
}
-
]
-
-
# Define bulk actions
-
@bulk_actions = [
-
{ value: "activate", label: "Activate" },
-
{ value: "deactivate", label: "Deactivate" },
-
{ value: "delete", label: "Delete" }
-
]
-
-
# Stats for the cards (using the format expected by the stats_cards partial)
-
@stats = {
-
total: @webhooks.count,
-
active: @webhooks.where(active: true).count,
-
deliveries: @webhooks.sum(:total_deliveries),
-
failed: @webhooks.sum(:failed_deliveries)
-
}
-
end
-
-
def show
-
@deliveries = @webhook.webhook_deliveries.recent.page(params[:page]).per(20)
-
end
-
-
def new
-
@webhook = Webhook.new
-
end
-
-
def create
-
@webhook = Webhook.new(webhook_params)
-
-
if @webhook.save
-
redirect_to admin_webhook_path(@webhook), notice: 'Webhook created successfully.'
-
else
-
render :new
-
end
-
end
-
-
def edit
-
end
-
-
def update
-
if @webhook.update(webhook_params)
-
redirect_to admin_webhook_path(@webhook), notice: 'Webhook updated successfully.'
-
else
-
render :edit
-
end
-
end
-
-
def destroy
-
@webhook.destroy
-
redirect_to admin_webhooks_path, notice: 'Webhook deleted successfully.'
-
end
-
-
def toggle_active
-
@webhook.update!(active: !@webhook.active?)
-
-
status = @webhook.active? ? 'activated' : 'deactivated'
-
redirect_to admin_webhooks_path, notice: "Webhook #{status}."
-
end
-
-
def test
-
# Send a test webhook
-
test_payload = {
-
message: 'This is a test webhook from RailsPress',
-
timestamp: Time.current.iso8601
-
}
-
-
delivery = @webhook.deliver('test.webhook', test_payload)
-
-
redirect_to admin_webhook_path(@webhook), notice: "Test webhook queued for delivery. Check delivery status below."
-
end
-
-
def bulk_action
-
webhook_ids = params[:webhook_ids]
-
action = params[:bulk_action]
-
-
return redirect_to admin_webhooks_path, alert: "No webhooks selected." if webhook_ids.blank?
-
-
webhooks = Webhook.where(id: webhook_ids)
-
-
case action
-
when 'activate'
-
webhooks.update_all(active: true)
-
redirect_to admin_webhooks_path, notice: "#{webhooks.count} webhook(s) activated."
-
when 'deactivate'
-
webhooks.update_all(active: false)
-
redirect_to admin_webhooks_path, notice: "#{webhooks.count} webhook(s) deactivated."
-
when 'delete'
-
webhooks.destroy_all
-
redirect_to admin_webhooks_path, notice: "#{webhooks.count} webhook(s) deleted."
-
else
-
redirect_to admin_webhooks_path, alert: "Invalid bulk action."
-
end
-
end
-
-
private
-
-
def set_webhook
-
@webhook = Webhook.find(params[:id])
-
end
-
-
-
def webhook_params
-
params.require(:webhook).permit(
-
:name,
-
:description,
-
:url,
-
:active,
-
:retry_limit,
-
:timeout,
-
events: []
-
)
-
end
-
-
def webhooks_json
-
@webhooks.map do |webhook|
-
{
-
id: webhook.id,
-
title: "<a href=\"#{admin_webhook_path(webhook)}\" class=\"text-indigo-600 hover:text-indigo-900 font-medium\">#{webhook.name}</a><br><small class=\"text-gray-500 font-mono\">#{webhook.url}</small>",
-
name: webhook.name, # For search functionality
-
events: format_events(webhook.events),
-
status: format_status(webhook),
-
stats: "<div><strong>#{webhook.total_deliveries}</strong> total<br><small class=\"text-gray-500\">#{webhook.failed_deliveries} failed</small></div>",
-
success_rate: webhook.success_rate,
-
created_at: webhook.created_at.iso8601,
-
actions: format_actions(webhook),
-
edit_url: edit_admin_webhook_path(webhook),
-
show_url: admin_webhook_path(webhook)
-
}
-
end
-
end
-
-
def format_events(events)
-
events.map { |event| "<span class=\"inline-flex px-2 py-1 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200\">#{event}</span>" }.join(' ')
-
end
-
-
def format_status(webhook)
-
status_html = if webhook.active?
-
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"><svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3"/></svg>Active</span>'
-
else
-
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-300">Inactive</span>'
-
end
-
-
# Add unhealthy indicator if needed
-
unless webhook.healthy?
-
status_html += '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 ml-2">Unhealthy</span>'
-
end
-
-
status_html
-
end
-
-
def format_actions(webhook)
-
%{
-
<div class="flex space-x-2">
-
<a href="#{admin_webhook_path(webhook)}" class="text-indigo-600 hover:text-indigo-900">View</a>
-
<a href="#{edit_admin_webhook_path(webhook)}" class="text-indigo-600 hover:text-indigo-900">Edit</a>
-
<a href="#{test_admin_webhook_path(webhook)}" data-method="post" class="text-green-600 hover:text-green-900">Test</a>
-
<a href="#{toggle_active_admin_webhook_path(webhook)}" data-method="patch" class="text-yellow-600 hover:text-yellow-900">#{webhook.active? ? 'Disable' : 'Enable'}</a>
-
<a href="#{admin_webhook_path(webhook)}" data-method="delete" data-confirm="Are you sure?" class="text-red-600 hover:text-red-900">Delete</a>
-
</div>
-
}
-
end
-
end
-
-
-
-
-
-
-
-
-
class Admin::WidgetsController < Admin::BaseController
-
before_action :set_widget, only: %i[ show edit update destroy ]
-
-
# GET /admin/widgets or /admin/widgets.json
-
def index
-
@widgets = Widget.all
-
end
-
-
# GET /admin/widgets/1 or /admin/widgets/1.json
-
def show
-
end
-
-
# GET /admin/widgets/new
-
def new
-
@widget = Widget.new
-
end
-
-
# GET /admin/widgets/1/edit
-
def edit
-
end
-
-
# POST /admin/widgets or /admin/widgets.json
-
def create
-
@widget = Widget.new(widget_params)
-
-
respond_to do |format|
-
if @widget.save
-
format.html { redirect_to [:admin, @widget], notice: "Widget was successfully created." }
-
format.json { render :show, status: :created, location: @widget }
-
else
-
format.html { render :new, status: :unprocessable_entity }
-
format.json { render json: @widget.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# PATCH/PUT /admin/widgets/1 or /admin/widgets/1.json
-
def update
-
respond_to do |format|
-
if @widget.update(widget_params)
-
format.html { redirect_to [:admin, @widget], notice: "Widget was successfully updated.", status: :see_other }
-
format.json { render :show, status: :ok, location: @widget }
-
else
-
format.html { render :edit, status: :unprocessable_entity }
-
format.json { render json: @widget.errors, status: :unprocessable_entity }
-
end
-
end
-
end
-
-
# DELETE /admin/widgets/1 or /admin/widgets/1.json
-
def destroy
-
@widget.destroy!
-
-
respond_to do |format|
-
format.html { redirect_to admin_widgets_path, notice: "Widget was successfully destroyed.", status: :see_other }
-
format.json { head :no_content }
-
end
-
end
-
-
private
-
# Use callbacks to share common setup or constraints between actions.
-
def set_widget
-
@widget = Widget.find(params[:id])
-
end
-
-
# Only allow a list of trusted parameters through.
-
def widget_params
-
params.fetch(:widget, {})
-
end
-
end
-
class AnalyticsController < ApplicationController
-
skip_before_action :authenticate_user!, only: [:track, :duration]
-
skip_before_action :verify_authenticity_token, only: [:track, :duration]
-
-
# POST /analytics/track
-
def track
-
# Extract data from request
-
data = JSON.parse(request.body.read) rescue {}
-
-
# Track the pageview
-
Pageview.track(request, {
-
title: data['title'],
-
user_id: current_user&.id,
-
session_id: data['session_id'] || cookies['_railspress_session_id'],
-
consented: data['consented'] || false,
-
metadata: {
-
screen_width: data['screen_width'],
-
screen_height: data['screen_height'],
-
viewport_width: data['viewport_width'],
-
viewport_height: data['viewport_height'],
-
language: data['language'],
-
timezone: data['timezone']
-
}
-
})
-
-
# Generate and set session cookie if not exists
-
unless cookies['_railspress_session_id']
-
cookies['_railspress_session_id'] = {
-
value: SecureRandom.hex(16),
-
expires: 30.days.from_now,
-
httponly: true,
-
same_site: :lax
-
}
-
end
-
-
head :ok
-
rescue => e
-
Rails.logger.error "Analytics tracking error: #{e.message}"
-
head :ok # Always return OK to not break user experience
-
end
-
-
# POST /analytics/duration
-
def duration
-
data = JSON.parse(request.body.read) rescue {}
-
-
# Find recent pageview for this path and session
-
session_id = cookies['_railspress_session_id']
-
pageview = Pageview.where(
-
path: data['path'],
-
session_id: session_id
-
).where('visited_at >= ?', 10.minutes.ago).last
-
-
if pageview
-
pageview.update(duration: data['duration'])
-
end
-
-
head :ok
-
rescue => e
-
Rails.logger.error "Duration tracking error: #{e.message}"
-
head :ok
-
end
-
-
# POST /analytics/reading
-
def reading
-
data = JSON.parse(request.body.read) rescue {}
-
-
# Find recent pageview for this path and session
-
session_id = data['session_id'] || cookies['_railspress_session_id']
-
pageview = Pageview.where(
-
path: data['path'],
-
session_id: session_id
-
).where('visited_at >= ?', 10.minutes.ago).last
-
-
if pageview
-
pageview.update(
-
reading_time: data['reading_time'],
-
scroll_depth: data['scroll_depth'],
-
completion_rate: data['completion_rate'],
-
time_on_page: data['reading_time'],
-
exit_intent: data['exit_intent'],
-
is_reader: data['is_reader'] || false,
-
engagement_score: data['engagement_score'] || 0
-
)
-
end
-
-
head :ok
-
rescue => e
-
Rails.logger.error "Reading tracking error: #{e.message}"
-
head :ok
-
end
-
-
def api_docs
-
render 'analytics_api_documentation', layout: false
-
end
-
-
def examples
-
render 'examples/analytics_plugin_example', layout: false
-
end
-
end
-
-
-
-
-
-
-
-
-
class Api::V1::AiAgentsController < ApplicationController
-
before_action :authenticate_api_user!
-
before_action :set_agent, only: [:execute, :show, :update, :destroy]
-
-
# GET /api/v1/ai_agents
-
def index
-
agents = AiAgent.active.ordered.includes(:ai_provider)
-
-
render json: {
-
success: true,
-
agents: agents.map { |agent| agent_json(agent) },
-
total: agents.count
-
}
-
end
-
-
# GET /api/v1/ai_agents/:id
-
def show
-
render json: {
-
success: true,
-
agent: agent_json(@agent, detailed: true)
-
}
-
end
-
-
# POST /api/v1/ai_agents
-
def create
-
provider = AiProvider.find(params[:ai_provider_id])
-
-
agent = AiAgent.new(agent_params.merge(ai_provider: provider))
-
-
if agent.save
-
render json: {
-
success: true,
-
agent: agent_json(agent, detailed: true),
-
message: 'AI Agent created successfully'
-
}, status: :created
-
else
-
render json: {
-
success: false,
-
errors: agent.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /api/v1/ai_agents/:id
-
def update
-
if @agent.update(agent_params)
-
render json: {
-
success: true,
-
agent: agent_json(@agent, detailed: true),
-
message: 'AI Agent updated successfully'
-
}
-
else
-
render json: {
-
success: false,
-
errors: @agent.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/ai_agents/:id
-
def destroy
-
if @agent.destroy
-
render json: {
-
success: true,
-
message: 'AI Agent deleted successfully'
-
}
-
else
-
render json: {
-
success: false,
-
error: 'Failed to delete agent'
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# POST /api/v1/ai_agents/execute
-
def execute
-
user_input = params[:user_input] || ""
-
context = params[:context] || {}
-
-
begin
-
result = @agent.execute(user_input, context)
-
-
render json: {
-
success: true,
-
result: result,
-
agent: {
-
id: @agent.id,
-
name: @agent.name,
-
type: @agent.agent_type
-
}
-
}
-
rescue => e
-
render json: {
-
success: false,
-
error: e.message
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# POST /api/v1/ai_agents/execute/:agent_type
-
def execute_by_type
-
agent_type = params[:agent_type]
-
user_input = params[:user_input] || ""
-
context = params[:context] || {}
-
-
agent = AiAgent.active.find_by(agent_type: agent_type)
-
-
unless agent
-
render json: {
-
success: false,
-
error: "No active agent found for type: #{agent_type}"
-
}, status: :not_found
-
return
-
end
-
-
begin
-
result = agent.execute(user_input, context)
-
-
render json: {
-
success: true,
-
result: result,
-
agent: {
-
id: agent.id,
-
name: agent.name,
-
type: agent.agent_type
-
}
-
}
-
rescue => e
-
render json: {
-
success: false,
-
error: e.message
-
}, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def set_agent
-
@agent = AiAgent.active.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
render json: {
-
success: false,
-
error: "Agent not found or inactive"
-
}, status: :not_found
-
end
-
-
def agent_params
-
params.require(:ai_agent).permit(
-
:name,
-
:agent_type,
-
:prompt,
-
:content,
-
:guidelines,
-
:rules,
-
:tasks,
-
:master_prompt,
-
:active,
-
:position
-
)
-
end
-
-
def agent_json(agent, detailed: false)
-
json = {
-
id: agent.id,
-
name: agent.name,
-
type: agent.agent_type,
-
active: agent.active,
-
provider: {
-
id: agent.ai_provider_id,
-
name: agent.ai_provider.name,
-
type: agent.ai_provider.provider_type
-
}
-
}
-
-
if detailed
-
json.merge!({
-
prompt: agent.prompt,
-
content: agent.content,
-
guidelines: agent.guidelines,
-
rules: agent.rules,
-
tasks: agent.tasks,
-
master_prompt: agent.master_prompt,
-
position: agent.position,
-
created_at: agent.created_at,
-
updated_at: agent.updated_at
-
})
-
end
-
-
json
-
end
-
-
def authenticate_api_user!
-
# For now, we'll allow any authenticated user
-
# You can add more specific authentication logic here
-
unless current_user
-
render json: {
-
success: false,
-
error: "Authentication required"
-
}, status: :unauthorized
-
end
-
end
-
end
-
class Api::V1::AiProvidersController < ApplicationController
-
before_action :authenticate_api_user!
-
before_action :require_admin!, except: [:index, :show]
-
before_action :set_provider, only: [:show, :update, :destroy, :toggle]
-
-
# GET /api/v1/ai_providers
-
def index
-
providers = AiProvider.ordered.all
-
-
render json: {
-
success: true,
-
providers: providers.map { |p| provider_json(p) },
-
total: providers.count
-
}
-
end
-
-
# GET /api/v1/ai_providers/:id
-
def show
-
render json: {
-
success: true,
-
provider: provider_json(@provider, detailed: true)
-
}
-
end
-
-
# POST /api/v1/ai_providers
-
def create
-
provider = AiProvider.new(provider_params)
-
-
if provider.save
-
render json: {
-
success: true,
-
provider: provider_json(provider, detailed: true),
-
message: 'AI Provider created successfully'
-
}, status: :created
-
else
-
render json: {
-
success: false,
-
errors: provider.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /api/v1/ai_providers/:id
-
def update
-
if @provider.update(provider_params)
-
render json: {
-
success: true,
-
provider: provider_json(@provider, detailed: true),
-
message: 'AI Provider updated successfully'
-
}
-
else
-
render json: {
-
success: false,
-
errors: @provider.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/ai_providers/:id
-
def destroy
-
if @provider.ai_agents.any?
-
render json: {
-
success: false,
-
error: 'Cannot delete provider with active agents'
-
}, status: :unprocessable_entity
-
return
-
end
-
-
if @provider.destroy
-
render json: {
-
success: true,
-
message: 'AI Provider deleted successfully'
-
}
-
else
-
render json: {
-
success: false,
-
error: 'Failed to delete provider'
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /api/v1/ai_providers/:id/toggle
-
def toggle
-
@provider.update!(active: !@provider.active)
-
-
render json: {
-
success: true,
-
provider: provider_json(@provider),
-
message: "Provider #{@provider.active ? 'activated' : 'deactivated'}"
-
}
-
end
-
-
private
-
-
def set_provider
-
@provider = AiProvider.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
render json: {
-
success: false,
-
error: "Provider not found"
-
}, status: :not_found
-
end
-
-
def provider_params
-
params.require(:ai_provider).permit(
-
:name,
-
:provider_type,
-
:api_key,
-
:api_url,
-
:model_identifier,
-
:max_tokens,
-
:temperature,
-
:active,
-
:position
-
)
-
end
-
-
def provider_json(provider, detailed: false)
-
json = {
-
id: provider.id,
-
name: provider.name,
-
type: provider.provider_type,
-
model: provider.model_identifier,
-
active: provider.active
-
}
-
-
if detailed
-
json.merge!({
-
api_url: provider.api_url,
-
max_tokens: provider.max_tokens,
-
temperature: provider.temperature,
-
position: provider.position,
-
agents_count: provider.ai_agents.count,
-
created_at: provider.created_at,
-
updated_at: provider.updated_at
-
})
-
end
-
-
json
-
end
-
-
def authenticate_api_user!
-
unless current_user
-
render json: {
-
success: false,
-
error: "Authentication required"
-
}, status: :unauthorized
-
end
-
end
-
-
def require_admin!
-
unless current_user&.administrator?
-
render json: {
-
success: false,
-
error: "Admin access required"
-
}, status: :forbidden
-
end
-
end
-
end
-
-
-
-
-
-
class Api::V1::AiSeoController < Api::V1::BaseController
-
# POST /api/v1/ai_seo/generate
-
def generate
-
content_type = params[:content_type] # 'post' or 'page'
-
content_id = params[:content_id]
-
-
unless content_type.present? && content_id.present?
-
return render_error('Missing content_type or content_id', 400)
-
end
-
-
result = AiSeo.generate_seo(content_type, content_id)
-
-
if result[:success]
-
render_success(result, 'SEO generated successfully')
-
else
-
render_error(result[:error] || 'Failed to generate SEO', 422)
-
end
-
end
-
-
# POST /api/v1/ai_seo/analyze
-
def analyze
-
content_text = params[:content]
-
-
unless content_text.present?
-
return render_error('Missing content parameter', 400)
-
end
-
-
plugin = Railspress::PluginSystem.get_plugin('ai_seo')
-
unless plugin
-
return render_error('AI SEO plugin not active', 503)
-
end
-
-
begin
-
# Create a temporary object for analysis
-
temp_object = OpenStruct.new(
-
title: params[:title] || 'Untitled',
-
content: OpenStruct.new(to_plain_text: content_text)
-
)
-
-
ai_response = plugin.send(:call_ai_api, content_text, temp_object)
-
seo_data = plugin.send(:parse_ai_response, ai_response)
-
-
render_success(seo_data, 'Content analyzed successfully')
-
rescue => e
-
render_error("Analysis failed: #{e.message}", 500)
-
end
-
end
-
-
# GET /api/v1/ai_seo/status
-
def status
-
plugin = Railspress::PluginSystem.get_plugin('ai_seo')
-
-
unless plugin
-
return render json: {
-
active: false,
-
configured: false,
-
message: 'AI SEO plugin not active'
-
}
-
end
-
-
render json: {
-
active: true,
-
configured: plugin.enabled?,
-
provider: plugin.get_setting('ai_provider'),
-
model: plugin.get_setting('model'),
-
auto_generate: plugin.get_setting('auto_generate_on_save'),
-
rate_limit: {
-
max_per_hour: plugin.get_setting('max_requests_per_hour'),
-
current: plugin.send(:get_request_count)
-
}
-
}
-
end
-
-
# POST /api/v1/ai_seo/batch_generate
-
def batch_generate
-
content_type = params[:content_type]
-
content_ids = params[:content_ids] # Array of IDs
-
-
unless content_type.present? && content_ids.is_a?(Array)
-
return render_error('Missing or invalid parameters', 400)
-
end
-
-
results = []
-
content_ids.each do |content_id|
-
result = AiSeo.generate_seo(content_type, content_id)
-
results << {
-
content_id: content_id,
-
success: result[:success],
-
data: result[:success] ? result : { error: result[:error] }
-
}
-
end
-
-
render_success({
-
total: results.count,
-
successful: results.count { |r| r[:success] },
-
failed: results.count { |r| !r[:success] },
-
results: results
-
}, 'Batch generation completed')
-
end
-
end
-
-
-
-
-
-
-
-
-
# frozen_string_literal: true
-
-
class Api::V1::AnalyticsController < Api::V1::BaseController
-
before_action :authenticate_api_user!
-
before_action :set_tenant
-
-
# GET /api/v1/analytics/posts/:id
-
def post_analytics
-
post = Post.find(params[:id])
-
-
analytics_data = ContentAnalyticsService.post_analytics(
-
post.id,
-
period: params[:period]&.to_sym || :month
-
)
-
-
render json: {
-
success: true,
-
data: {
-
post: {
-
id: post.id,
-
title: post.title,
-
slug: post.slug,
-
published_at: post.published_at,
-
status: post.status
-
},
-
analytics: analytics_data,
-
period: params[:period] || 'month',
-
generated_at: Time.current.iso8601
-
}
-
}
-
rescue ActiveRecord::RecordNotFound
-
render json: {
-
success: false,
-
error: 'Post not found',
-
code: 'POST_NOT_FOUND'
-
}, status: :not_found
-
rescue => e
-
Rails.logger.error "Post analytics API error: #{e.message}"
-
render json: {
-
success: false,
-
error: 'Failed to fetch post analytics',
-
code: 'ANALYTICS_ERROR'
-
}, status: :internal_server_error
-
end
-
-
# GET /api/v1/analytics/pages/:id
-
def page_analytics
-
page = Page.find(params[:id])
-
-
analytics_data = ContentAnalyticsService.page_analytics(
-
page.id,
-
period: params[:period]&.to_sym || :month
-
)
-
-
render json: {
-
success: true,
-
data: {
-
page: {
-
id: page.id,
-
title: page.title,
-
slug: page.slug,
-
created_at: page.created_at,
-
status: page.status
-
},
-
analytics: analytics_data,
-
period: params[:period] || 'month',
-
generated_at: Time.current.iso8601
-
}
-
}
-
rescue ActiveRecord::RecordNotFound
-
render json: {
-
success: false,
-
error: 'Page not found',
-
code: 'PAGE_NOT_FOUND'
-
}, status: :not_found
-
rescue => e
-
Rails.logger.error "Page analytics API error: #{e.message}"
-
render json: {
-
success: false,
-
error: 'Failed to fetch page analytics',
-
code: 'ANALYTICS_ERROR'
-
}, status: :internal_server_error
-
end
-
-
# GET /api/v1/analytics/posts
-
def posts_analytics
-
period = params[:period]&.to_sym || :month
-
limit = [params[:limit]&.to_i || 10, 100].min
-
-
posts_data = ContentAnalyticsService.top_performing_content(
-
content_type: 'posts',
-
period: period,
-
limit: limit
-
)
-
-
render json: {
-
success: true,
-
data: {
-
posts: posts_data,
-
period: period.to_s,
-
limit: limit,
-
generated_at: Time.current.iso8601
-
}
-
}
-
rescue => e
-
Rails.logger.error "Posts analytics API error: #{e.message}"
-
render json: {
-
success: false,
-
error: 'Failed to fetch posts analytics',
-
code: 'ANALYTICS_ERROR'
-
}, status: :internal_server_error
-
end
-
-
# GET /api/v1/analytics/pages
-
def pages_analytics
-
period = params[:period]&.to_sym || :month
-
limit = [params[:limit]&.to_i || 10, 100].min
-
-
pages_data = ContentAnalyticsService.top_performing_content(
-
content_type: 'pages',
-
period: period,
-
limit: limit
-
)
-
-
render json: {
-
success: true,
-
data: {
-
pages: pages_data,
-
period: period.to_s,
-
limit: limit,
-
generated_at: Time.current.iso8601
-
}
-
}
-
rescue => e
-
Rails.logger.error "Pages analytics API error: #{e.message}"
-
render json: {
-
success: false,
-
error: 'Failed to fetch pages analytics',
-
code: 'ANALYTICS_ERROR'
-
}, status: :internal_server_error
-
end
-
-
# GET /api/v1/analytics/overview
-
def overview
-
period = params[:period]&.to_sym || :month
-
-
overview_data = {
-
total_pageviews: AnalyticsService.total_pageviews(period: period),
-
unique_visitors: AnalyticsService.unique_visitors(period: period),
-
top_posts: ContentAnalyticsService.top_performing_content(content_type: 'posts', period: period, limit: 5),
-
top_pages: ContentAnalyticsService.top_performing_content(content_type: 'pages', period: period, limit: 5),
-
traffic_sources: AnalyticsService.traffic_sources(period: period),
-
audience_insights: AnalyticsService.audience_insights(period: period)
-
}
-
-
render json: {
-
success: true,
-
data: {
-
overview: overview_data,
-
period: period.to_s,
-
generated_at: Time.current.iso8601
-
}
-
}
-
rescue => e
-
Rails.logger.error "Analytics overview API error: #{e.message}"
-
render json: {
-
success: false,
-
error: 'Failed to fetch analytics overview',
-
code: 'ANALYTICS_ERROR'
-
}, status: :internal_server_error
-
end
-
-
# GET /api/v1/analytics/realtime
-
def realtime
-
realtime_data = AnalyticsService.realtime_stats
-
-
render json: {
-
success: true,
-
data: {
-
realtime: realtime_data,
-
generated_at: Time.current.iso8601
-
}
-
}
-
rescue => e
-
Rails.logger.error "Realtime analytics API error: #{e.message}"
-
render json: {
-
success: false,
-
error: 'Failed to fetch realtime analytics',
-
code: 'ANALYTICS_ERROR'
-
}, status: :internal_server_error
-
end
-
-
private
-
-
def authenticate_api_user!
-
# Check for API key authentication
-
api_key = request.headers['Authorization']&.gsub(/^Bearer /, '') || params[:api_key]
-
-
if api_key.blank?
-
render json: {
-
success: false,
-
error: 'API key required',
-
code: 'MISSING_API_KEY'
-
}, status: :unauthorized
-
return
-
end
-
-
@current_user = User.find_by(api_key: api_key)
-
-
unless @current_user&.administrator?
-
render json: {
-
success: false,
-
error: 'Invalid API key or insufficient permissions',
-
code: 'INVALID_API_KEY'
-
}, status: :unauthorized
-
end
-
end
-
-
def set_tenant
-
ActsAsTenant.current_tenant = @current_user&.tenant
-
end
-
end
-
module Api
-
module V1
-
class AuthController < ApplicationController
-
skip_before_action :verify_authenticity_token
-
-
# POST /api/v1/auth/login
-
def login
-
user = User.find_by(email: params[:email])
-
-
if user&.valid_password?(params[:password])
-
render json: {
-
success: true,
-
data: {
-
user: {
-
id: user.id,
-
email: user.email,
-
role: user.role
-
},
-
api_token: user.api_token,
-
message: 'Login successful'
-
}
-
}, status: :ok
-
else
-
render json: {
-
success: false,
-
error: 'Invalid email or password'
-
}, status: :unauthorized
-
end
-
end
-
-
# POST /api/v1/auth/register
-
def register
-
user = User.new(registration_params)
-
user.role = :subscriber # New users are subscribers by default
-
-
if user.save
-
render json: {
-
success: true,
-
data: {
-
user: {
-
id: user.id,
-
email: user.email,
-
role: user.role
-
},
-
api_token: user.api_token,
-
message: 'Registration successful'
-
}
-
}, status: :created
-
else
-
render json: {
-
success: false,
-
error: user.errors.full_messages.join(', ')
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# POST /api/v1/auth/validate
-
def validate_token
-
token = request.headers['Authorization']&.split(' ')&.last
-
user = User.find_by(api_token: token)
-
-
if user
-
render json: {
-
success: true,
-
data: {
-
valid: true,
-
user: {
-
id: user.id,
-
email: user.email,
-
role: user.role
-
}
-
}
-
}
-
else
-
render json: {
-
success: false,
-
data: { valid: false },
-
error: 'Invalid token'
-
}, status: :unauthorized
-
end
-
end
-
-
private
-
-
def registration_params
-
params.permit(:email, :password, :password_confirmation)
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
class Api::V1::BaseController < ActionController::API
-
# Skip Devise authentication
-
skip_before_action :authenticate_user! if respond_to?(:authenticate_user!)
-
-
# Set content type to JSON
-
before_action :set_content_type
-
-
# This will be overridden by child controllers that need authentication
-
def authenticate_api_key
-
# Default implementation - do nothing
-
end
-
-
private
-
-
def set_content_type
-
response.headers['Content-Type'] = 'application/json'
-
end
-
-
def render_success(data, meta = {}, status = :ok)
-
response_data = {
-
success: true,
-
data: data
-
}
-
response_data[:meta] = meta if meta.present?
-
-
render json: response_data, status: status
-
end
-
-
def current_api_user
-
@api_user
-
end
-
-
def paginate(collection, per_page = 20)
-
page = params[:page]&.to_i || 1
-
per_page = params[:per_page]&.to_i || per_page
-
per_page = [per_page, 100].min # Cap at 100 items per page
-
-
collection.page(page).per(per_page)
-
end
-
end
-
module Api
-
module V1
-
class CategoriesController < BaseController
-
skip_before_action :authenticate_api_user!, only: [:index, :show]
-
before_action :set_category, only: [:show, :update, :destroy]
-
-
# GET /api/v1/categories
-
def index
-
categories = Term.for_taxonomy('category').includes(:term_relationships, :children)
-
-
# Filter by parent
-
categories = categories.where(parent_id: params[:parent_id]) if params[:parent_id].present?
-
categories = categories.root_terms if params[:root_only] == 'true'
-
-
# Search
-
categories = categories.where('name LIKE ?', "%#{params[:q]}%") if params[:q].present?
-
-
@categories = paginate(categories.ordered)
-
-
render_success(
-
@categories.map { |cat| category_serializer(cat) }
-
)
-
end
-
-
# GET /api/v1/categories/:id
-
def show
-
render_success(category_serializer(@category, detailed: true))
-
end
-
-
# POST /api/v1/categories
-
def create
-
unless current_api_user.can_edit_others_posts?
-
return render_error('You do not have permission to create categories', :forbidden)
-
end
-
-
@category = Term.new(category_params.merge(taxonomy: Taxonomy.categories))
-
-
if @category.save
-
render_success(category_serializer(@category), {}, :created)
-
else
-
render_error(@category.errors.full_messages.join(', '))
-
end
-
end
-
-
# PATCH/PUT /api/v1/categories/:id
-
def update
-
unless current_api_user.can_edit_others_posts?
-
return render_error('You do not have permission to edit categories', :forbidden)
-
end
-
-
if @category.update(category_params)
-
render_success(category_serializer(@category))
-
else
-
render_error(@category.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/categories/:id
-
def destroy
-
unless current_api_user.administrator?
-
return render_error('Only administrators can delete categories', :forbidden)
-
end
-
-
@category.destroy
-
render_success({ message: 'Category deleted successfully' })
-
end
-
-
private
-
-
def set_category
-
@category = Term.for_taxonomy('category').friendly.find(params[:id])
-
end
-
-
def category_params
-
params.require(:category).permit(:name, :slug, :description, :parent_id)
-
end
-
-
def category_serializer(category, detailed: false)
-
data = {
-
id: category.id,
-
name: category.name,
-
slug: category.slug,
-
description: category.description,
-
post_count: category.post_count,
-
parent: category.parent ? { id: category.parent.id, name: category.parent.name, slug: category.parent.slug } : nil,
-
children_count: category.children.count,
-
url: category_url(category.slug)
-
}
-
-
if detailed
-
data.merge!(
-
children: category.children.map { |c| { id: c.id, name: c.name, slug: c.slug } },
-
recent_posts: category.posts.published.recent.limit(5).map { |p|
-
{ id: p.id, title: p.title, slug: p.slug, published_at: p.published_at }
-
}
-
)
-
end
-
-
data
-
end
-
end
-
end
-
end
-
-
-
-
module Api
-
module V1
-
class ChannelsController < BaseController
-
before_action :set_channel, only: [:show, :update, :destroy]
-
-
# GET /api/v1/channels
-
def index
-
channels = Channel.all.order(:name)
-
-
render_success(
-
channels.map { |channel| channel_serializer(channel) },
-
{ total: channels.count }
-
)
-
end
-
-
# GET /api/v1/channels/:id
-
def show
-
render_success(channel_serializer(@channel, detailed: true))
-
end
-
-
# POST /api/v1/channels
-
def create
-
unless current_api_user&.can_manage_channels?
-
return render_error('You do not have permission to create channels', :forbidden)
-
end
-
-
@channel = Channel.new(channel_params)
-
-
if @channel.save
-
render_success(channel_serializer(@channel), {}, :created)
-
else
-
render_error(@channel.errors.full_messages.join(', '))
-
end
-
end
-
-
# PATCH/PUT /api/v1/channels/:id
-
def update
-
unless current_api_user&.can_manage_channels?
-
return render_error('You do not have permission to update channels', :forbidden)
-
end
-
-
if @channel.update(channel_params)
-
render_success(channel_serializer(@channel))
-
else
-
render_error(@channel.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/channels/:id
-
def destroy
-
unless current_api_user&.can_manage_channels?
-
return render_error('You do not have permission to delete channels', :forbidden)
-
end
-
-
@channel.destroy
-
render_success({ message: 'Channel deleted successfully' })
-
end
-
-
private
-
-
def set_channel
-
@channel = Channel.find(params[:id])
-
end
-
-
def channel_params
-
params.require(:channel).permit(:name, :slug, :domain, :locale, :enabled, metadata: {}, settings: {})
-
end
-
-
def channel_serializer(channel, detailed: false)
-
data = {
-
id: channel.id,
-
name: channel.name,
-
slug: channel.slug,
-
domain: channel.domain,
-
locale: channel.locale,
-
enabled: channel.enabled,
-
metadata: channel.metadata,
-
settings: channel.settings,
-
created_at: channel.created_at,
-
updated_at: channel.updated_at,
-
content_stats: {
-
posts_count: channel.posts.count,
-
pages_count: channel.pages.count,
-
media_count: channel.media.count
-
},
-
override_stats: {
-
total_overrides: channel.channel_overrides.count,
-
active_overrides: channel.channel_overrides.enabled.count,
-
exclusions: channel.channel_overrides.exclusions.count,
-
data_overrides: channel.channel_overrides.overrides.count
-
}
-
}
-
-
if detailed
-
data.merge!(
-
overrides: channel.channel_overrides.includes(:resource).map do |override|
-
{
-
id: override.id,
-
resource_type: override.resource_type,
-
resource_id: override.resource_id,
-
resource_name: override.resource_name,
-
kind: override.kind,
-
path: override.path,
-
data: override.data,
-
enabled: override.enabled,
-
created_at: override.created_at,
-
updated_at: override.updated_at
-
}
-
end
-
)
-
end
-
-
data
-
end
-
end
-
end
-
end
-
module Api
-
module V1
-
class CommentsController < BaseController
-
# No authentication required for public comment creation
-
before_action :set_comment, only: [:show, :update, :destroy, :approve, :spam]
-
-
# GET /api/v1/comments
-
def index
-
comments = Comment.kept.includes(:user, :commentable)
-
-
# Filter by status
-
comments = comments.where(status: params[:status]) if params[:status].present?
-
-
# Filter by commentable
-
if params[:post_id].present?
-
comments = comments.where(commentable_type: 'Post', commentable_id: params[:post_id])
-
elsif params[:page_id].present?
-
comments = comments.where(commentable_type: 'Page', commentable_id: params[:page_id])
-
end
-
-
# Only approved for non-authenticated users
-
unless @api_user&.can_edit_others_posts?
-
comments = comments.approved
-
end
-
-
# Root comments only or include replies
-
comments = comments.root_comments if params[:root_only] == 'true'
-
-
@comments = paginate(comments.recent)
-
-
render_success(
-
@comments.map { |comment| comment_serializer(comment) }
-
)
-
end
-
-
# GET /api/v1/comments/:id
-
def show
-
render_success(comment_serializer(@comment, detailed: true))
-
end
-
-
# POST /api/v1/comments
-
def create
-
# Check if comments are enabled
-
unless SiteSetting.get('comments_enabled', true)
-
return render json: { error: 'Comments are disabled for this site' }, status: :forbidden
-
end
-
-
# Check if registration is required and user is not logged in
-
if SiteSetting.get('comment_registration_required', false) && !@api_user
-
return render json: { error: 'You must be logged in to post comments' }, status: :unauthorized
-
end
-
-
@comment = Comment.new(comment_params)
-
@comment.user = @api_user if @api_user
-
-
# Check Akismet if enabled
-
if akismet_enabled?
-
akismet_data = {
-
user_ip: request.remote_ip,
-
user_agent: request.user_agent,
-
referrer: request.referer,
-
permalink: commentable_url(@comment.commentable),
-
comment_type: 'comment',
-
comment_author: @comment.author_name || @comment.user&.email,
-
comment_author_email: @comment.author_email || @comment.user&.email,
-
comment_author_url: @comment.author_url,
-
comment_content: @comment.content
-
}
-
-
akismet = AkismetService.new(akismet_api_key, site_url)
-
if akismet.spam?(akismet_data)
-
@comment.status = :spam
-
else
-
@comment.status = SiteSetting.get('comments_moderation', true) ? :pending : :approved
-
end
-
else
-
@comment.status = SiteSetting.get('comments_moderation', true) ? :pending : :approved
-
end
-
-
if @comment.save
-
render json: { success: true, comment: { id: @comment.id, status: @comment.status } }, status: :created
-
else
-
render json: { error: @comment.errors.full_messages.join(', ') }, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /api/v1/comments/:id
-
def update
-
unless can_edit_comment?
-
return render_error('You do not have permission to edit this comment', :forbidden)
-
end
-
-
if @comment.update(comment_update_params)
-
render_success(comment_serializer(@comment))
-
else
-
render_error(@comment.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/comments/:id
-
def destroy
-
unless can_delete_comment?
-
return render_error('You do not have permission to delete this comment', :forbidden)
-
end
-
-
@comment.destroy
-
render_success({ message: 'Comment deleted successfully' })
-
end
-
-
# PATCH /api/v1/comments/:id/approve
-
def approve
-
unless current_api_user.can_edit_others_posts?
-
return render_error('You do not have permission to approve comments', :forbidden)
-
end
-
-
@comment.update(status: :approved)
-
render_success(comment_serializer(@comment))
-
end
-
-
# PATCH /api/v1/comments/:id/spam
-
def spam
-
unless current_api_user.can_edit_others_posts?
-
return render_error('You do not have permission to mark comments as spam', :forbidden)
-
end
-
-
@comment.update(status: :spam)
-
render_success(comment_serializer(@comment))
-
end
-
-
private
-
-
def set_comment
-
@comment = Comment.find(params[:id])
-
end
-
-
def can_edit_comment?
-
return true if @api_user&.can_edit_others_posts?
-
@comment.user_id == @api_user&.id
-
end
-
-
def can_delete_comment?
-
return true if @api_user&.administrator?
-
@comment.user_id == @api_user&.id
-
end
-
-
def comment_params
-
params.require(:comment).permit(
-
:content, :author_name, :author_email, :author_url, :author_ip, :author_agent,
-
:comment_type, :comment_approved, :comment_parent_id,
-
:commentable_type, :commentable_id, :parent_id
-
)
-
end
-
-
def comment_update_params
-
if current_api_user&.can_edit_others_posts?
-
params.require(:comment).permit(:content, :status, :author_name, :author_email, :author_url)
-
else
-
params.require(:comment).permit(:content)
-
end
-
end
-
-
def comment_serializer(comment, detailed: false)
-
data = {
-
id: comment.id,
-
content: comment.content,
-
author: comment.author,
-
author_email: comment.author_email,
-
author_url: comment.author_url,
-
status: comment.status,
-
created_at: comment.created_at,
-
updated_at: comment.updated_at,
-
commentable: {
-
type: comment.commentable_type,
-
id: comment.commentable_id,
-
title: comment.commentable.try(:title)
-
},
-
parent_id: comment.parent_id,
-
replies_count: comment.replies.count
-
}
-
-
if detailed
-
data.merge!(
-
replies: comment.replies.approved.map { |r| comment_serializer(r) },
-
user: comment.user ? { id: comment.user.id, email: comment.user.email } : nil
-
)
-
end
-
-
data
-
end
-
-
def akismet_enabled?
-
SiteSetting.get('akismet_enabled', false) && SiteSetting.get('akismet_api_key', '').present?
-
end
-
-
def akismet_api_key
-
SiteSetting.get('akismet_api_key', '')
-
end
-
-
def site_url
-
SiteSetting.get('site_url', 'http://localhost:3000')
-
end
-
-
def commentable_url(commentable)
-
case commentable
-
when Post
-
"#{site_url}/posts/#{commentable.slug}"
-
when Page
-
"#{site_url}/pages/#{commentable.slug}"
-
else
-
site_url
-
end
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
class Api::V1::ConsentController < Api::V1::BaseController
-
before_action :authenticate_user!, only: [:create, :update, :withdraw]
-
before_action :set_consent_configuration, only: [:configuration, :region]
-
-
# GET /api/v1/consent/configuration
-
def configuration
-
render json: {
-
consent_categories_with_defaults: @consent_config.consent_categories_with_defaults,
-
banner_settings_with_defaults: @consent_config.banner_settings_with_defaults,
-
geolocation_settings_with_defaults: @consent_config.geolocation_settings_with_defaults,
-
pixel_consent_mapping_with_defaults: @consent_config.pixel_consent_mapping_with_defaults,
-
version: @consent_config.version || '1.0'
-
}
-
end
-
-
# GET /api/v1/consent/region
-
def region
-
user_ip = request.remote_ip || request.env['HTTP_X_FORWARDED_FOR']&.split(',')&.first
-
-
begin
-
detected_region = @consent_config.get_region_from_ip(user_ip)
-
-
render json: {
-
region: detected_region,
-
ip: user_ip,
-
timestamp: Time.current.iso8601
-
}
-
rescue => e
-
Rails.logger.error "Region detection error: #{e.message}"
-
render json: {
-
region: 'unknown',
-
ip: user_ip,
-
timestamp: Time.current.iso8601,
-
error: 'Region detection failed'
-
}
-
end
-
end
-
-
# POST /api/v1/consent
-
def create
-
consent_params = params.require(:consent).permit!
-
region = params[:region]
-
timestamp = params[:timestamp]
-
-
begin
-
# Save consent for each category
-
saved_consents = []
-
-
consent_params.each do |category, consent_data|
-
next unless consent_data[:granted]
-
-
# Find or create user consent
-
user_consent = current_user.user_consents.find_or_initialize_by(
-
consent_type: category
-
)
-
-
user_consent.assign_attributes(
-
consent_text: consent_data[:consent_text],
-
ip_address: consent_data[:ip_address],
-
user_agent: consent_data[:user_agent],
-
granted: true,
-
granted_at: Time.parse(consent_data[:granted_at]),
-
withdrawn_at: nil,
-
details: {
-
region: region,
-
timestamp: timestamp,
-
consent_version: @consent_config.version || '1.0'
-
}
-
)
-
-
user_consent.save!
-
saved_consents << user_consent
-
end
-
-
# Log consent event
-
log_consent_event('consent_granted', {
-
user_id: current_user.id,
-
consents: saved_consents.map(&:consent_type),
-
region: region,
-
timestamp: timestamp
-
})
-
-
render json: {
-
success: true,
-
message: 'Consent saved successfully',
-
consents: saved_consents.map do |consent|
-
{
-
type: consent.consent_type,
-
granted: consent.granted,
-
granted_at: consent.granted_at
-
}
-
end
-
}
-
-
rescue => e
-
Rails.logger.error "Consent save error: #{e.message}"
-
render json: {
-
success: false,
-
error: 'Failed to save consent'
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH /api/v1/consent
-
def update
-
consent_params = params.require(:consent).permit!
-
region = params[:region]
-
timestamp = params[:timestamp]
-
-
begin
-
# Update existing consents
-
updated_consents = []
-
-
consent_params.each do |category, consent_data|
-
user_consent = current_user.user_consents.find_by(consent_type: category)
-
next unless user_consent
-
-
if consent_data[:granted]
-
user_consent.update!(
-
granted: true,
-
granted_at: Time.parse(consent_data[:granted_at]),
-
withdrawn_at: nil,
-
details: user_consent.details.merge({
-
region: region,
-
timestamp: timestamp,
-
consent_version: @consent_config.version || '1.0'
-
})
-
)
-
else
-
user_consent.withdraw!
-
end
-
-
updated_consents << user_consent
-
end
-
-
# Log consent update event
-
log_consent_event('consent_updated', {
-
user_id: current_user.id,
-
consents: updated_consents.map(&:consent_type),
-
region: region,
-
timestamp: timestamp
-
})
-
-
render json: {
-
success: true,
-
message: 'Consent updated successfully',
-
consents: updated_consents.map do |consent|
-
{
-
type: consent.consent_type,
-
granted: consent.granted,
-
granted_at: consent.granted_at,
-
withdrawn_at: consent.withdrawn_at
-
}
-
end
-
}
-
-
rescue => e
-
Rails.logger.error "Consent update error: #{e.message}"
-
render json: {
-
success: false,
-
error: 'Failed to update consent'
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/consent/:consent_type
-
def withdraw
-
consent_type = params[:consent_type]
-
-
begin
-
user_consent = current_user.user_consents.find_by(consent_type: consent_type)
-
-
if user_consent
-
user_consent.withdraw!
-
-
# Log consent withdrawal event
-
log_consent_event('consent_withdrawn', {
-
user_id: current_user.id,
-
consent_type: consent_type,
-
timestamp: Time.current.iso8601
-
})
-
-
render json: {
-
success: true,
-
message: 'Consent withdrawn successfully'
-
}
-
else
-
render json: {
-
success: false,
-
error: 'Consent not found'
-
}, status: :not_found
-
end
-
-
rescue => e
-
Rails.logger.error "Consent withdrawal error: #{e.message}"
-
render json: {
-
success: false,
-
error: 'Failed to withdraw consent'
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# GET /api/v1/consent/status
-
def status
-
if user_signed_in?
-
consents = current_user.user_consents.includes(:user)
-
-
render json: {
-
user_id: current_user.id,
-
consents: consents.map do |consent|
-
{
-
type: consent.consent_type,
-
granted: consent.granted?,
-
granted_at: consent.granted_at,
-
withdrawn_at: consent.withdrawn_at,
-
details: consent.details
-
}
-
end,
-
timestamp: Time.current.iso8601
-
}
-
else
-
render json: {
-
user_id: nil,
-
consents: [],
-
timestamp: Time.current.iso8601
-
}
-
end
-
end
-
-
# GET /api/v1/consent/pixels
-
def pixels
-
# Get all active pixels with consent requirements
-
pixels = Pixel.active.includes(:tenant)
-
-
pixel_data = pixels.map do |pixel|
-
required_consent = @consent_config.get_consent_categories_for_pixel(pixel.pixel_type)
-
-
{
-
id: pixel.id,
-
name: pixel.name,
-
pixel_type: pixel.pixel_type,
-
pixel_id: pixel.pixel_id,
-
position: pixel.position,
-
required_consent: required_consent,
-
consent_required: @consent_config.is_pixel_consent_required?(pixel.pixel_type)
-
}
-
end
-
-
render json: {
-
pixels: pixel_data,
-
timestamp: Time.current.iso8601
-
}
-
end
-
-
private
-
-
def set_consent_configuration
-
@consent_config = ConsentConfiguration.active.first
-
-
unless @consent_config
-
render json: {
-
error: 'No active consent configuration found'
-
}, status: :not_found
-
end
-
end
-
-
def log_consent_event(event_type, data)
-
# Log to analytics if available
-
if defined?(AnalyticsEvent)
-
AnalyticsEvent.create!(
-
event_type: event_type,
-
user_id: data[:user_id],
-
properties: data,
-
timestamp: Time.current
-
)
-
end
-
-
# Log to Rails logger
-
Rails.logger.info "Consent Event: #{event_type} - #{data}"
-
end
-
end
-
class Api::V1::ContentTypesController < Api::V1::BaseController
-
before_action :set_content_type, only: [:show]
-
-
# GET /api/v1/content_types
-
def index
-
@content_types = ContentType.active.ordered
-
-
render json: {
-
data: @content_types.map { |ct| content_type_json(ct) },
-
meta: {
-
total: @content_types.count
-
}
-
}
-
end
-
-
# GET /api/v1/content_types/:ident
-
def show
-
render json: {
-
data: content_type_json(@content_type)
-
}
-
end
-
-
private
-
-
def set_content_type
-
@content_type = ContentType.find_by_ident(params[:id]) || ContentType.find(params[:id])
-
-
unless @content_type
-
render json: { error: 'Content type not found' }, status: :not_found
-
end
-
end
-
-
def content_type_json(content_type)
-
{
-
id: content_type.id,
-
ident: content_type.ident,
-
label: content_type.label,
-
singular: content_type.singular,
-
plural: content_type.plural,
-
description: content_type.description,
-
icon: content_type.icon,
-
public: content_type.public,
-
hierarchical: content_type.hierarchical,
-
has_archive: content_type.has_archive,
-
menu_position: content_type.menu_position,
-
supports: content_type.supports,
-
capabilities: content_type.capabilities,
-
rest_base: content_type.rest_endpoint,
-
active: content_type.active,
-
posts_count: content_type.posts.count,
-
created_at: content_type.created_at.iso8601,
-
updated_at: content_type.updated_at.iso8601,
-
_links: {
-
self: api_v1_content_type_url(content_type.ident),
-
posts: api_v1_posts_url(content_type: content_type.ident)
-
}
-
}
-
end
-
end
-
-
-
-
-
-
module Api
-
module V1
-
class DocsController < ApplicationController
-
skip_before_action :verify_authenticity_token
-
-
def index
-
@api_endpoints = build_endpoint_tree
-
render layout: 'api_docs'
-
end
-
-
private
-
-
def build_endpoint_tree
-
{
-
authentication: {
-
name: 'Authentication',
-
endpoints: [
-
{ method: 'POST', path: '/api/v1/auth/login', description: 'Login and get API token' },
-
{ method: 'POST', path: '/api/v1/auth/register', description: 'Register new user' },
-
{ method: 'POST', path: '/api/v1/auth/validate', description: 'Validate API token' }
-
]
-
},
-
posts: {
-
name: 'Posts',
-
endpoints: [
-
{ method: 'GET', path: '/api/v1/posts', description: 'List all posts' },
-
{ method: 'GET', path: '/api/v1/posts/:id', description: 'Get single post' },
-
{ method: 'POST', path: '/api/v1/posts', description: 'Create new post', auth: true },
-
{ method: 'PATCH', path: '/api/v1/posts/:id', description: 'Update post', auth: true },
-
{ method: 'DELETE', path: '/api/v1/posts/:id', description: 'Delete post', auth: true }
-
]
-
},
-
pages: {
-
name: 'Pages',
-
endpoints: [
-
{ method: 'GET', path: '/api/v1/pages', description: 'List all pages' },
-
{ method: 'GET', path: '/api/v1/pages/:id', description: 'Get single page' },
-
{ method: 'POST', path: '/api/v1/pages', description: 'Create new page', auth: true },
-
{ method: 'PATCH', path: '/api/v1/pages/:id', description: 'Update page', auth: true },
-
{ method: 'DELETE', path: '/api/v1/pages/:id', description: 'Delete page', auth: true }
-
]
-
},
-
categories: {
-
name: 'Categories',
-
endpoints: [
-
{ method: 'GET', path: '/api/v1/categories', description: 'List all categories' },
-
{ method: 'GET', path: '/api/v1/categories/:id', description: 'Get single category' },
-
{ method: 'POST', path: '/api/v1/categories', description: 'Create category', auth: true },
-
{ method: 'PATCH', path: '/api/v1/categories/:id', description: 'Update category', auth: true },
-
{ method: 'DELETE', path: '/api/v1/categories/:id', description: 'Delete category', auth: true }
-
]
-
},
-
tags: {
-
name: 'Tags',
-
endpoints: [
-
{ method: 'GET', path: '/api/v1/tags', description: 'List all tags' },
-
{ method: 'GET', path: '/api/v1/tags/:id', description: 'Get single tag' },
-
{ method: 'POST', path: '/api/v1/tags', description: 'Create tag', auth: true },
-
{ method: 'PATCH', path: '/api/v1/tags/:id', description: 'Update tag', auth: true },
-
{ method: 'DELETE', path: '/api/v1/tags/:id', description: 'Delete tag', auth: true }
-
]
-
},
-
comments: {
-
name: 'Comments',
-
endpoints: [
-
{ method: 'GET', path: '/api/v1/comments', description: 'List all comments' },
-
{ method: 'GET', path: '/api/v1/comments/:id', description: 'Get single comment' },
-
{ method: 'POST', path: '/api/v1/comments', description: 'Create comment' },
-
{ method: 'PATCH', path: '/api/v1/comments/:id/approve', description: 'Approve comment', auth: true },
-
{ method: 'PATCH', path: '/api/v1/comments/:id/spam', description: 'Mark as spam', auth: true },
-
{ method: 'DELETE', path: '/api/v1/comments/:id', description: 'Delete comment', auth: true }
-
]
-
},
-
media: {
-
name: 'Media',
-
endpoints: [
-
{ method: 'GET', path: '/api/v1/media', description: 'List all media' },
-
{ method: 'GET', path: '/api/v1/media/:id', description: 'Get single media' },
-
{ method: 'POST', path: '/api/v1/media', description: 'Upload media', auth: true },
-
{ method: 'PATCH', path: '/api/v1/media/:id', description: 'Update media', auth: true },
-
{ method: 'DELETE', path: '/api/v1/media/:id', description: 'Delete media', auth: true }
-
]
-
},
-
users: {
-
name: 'Users',
-
endpoints: [
-
{ method: 'GET', path: '/api/v1/users', description: 'List all users', auth: true, admin: true },
-
{ method: 'GET', path: '/api/v1/users/me', description: 'Get current user', auth: true },
-
{ method: 'PATCH', path: '/api/v1/users/update_profile', description: 'Update profile', auth: true },
-
{ method: 'POST', path: '/api/v1/users/regenerate_token', description: 'Regenerate API token', auth: true }
-
]
-
},
-
menus: {
-
name: 'Menus',
-
endpoints: [
-
{ method: 'GET', path: '/api/v1/menus', description: 'List all menus' },
-
{ method: 'GET', path: '/api/v1/menus/:id', description: 'Get menu with items' }
-
]
-
},
-
settings: {
-
name: 'Settings',
-
endpoints: [
-
{ method: 'GET', path: '/api/v1/settings', description: 'List all settings', auth: true },
-
{ method: 'GET', path: '/api/v1/settings/get/:key', description: 'Get setting value', auth: true },
-
{ method: 'POST', path: '/api/v1/settings', description: 'Create setting', auth: true, admin: true }
-
]
-
},
-
system: {
-
name: 'System',
-
endpoints: [
-
{ method: 'GET', path: '/api/v1/system/info', description: 'Get API information' },
-
{ method: 'GET', path: '/api/v1/system/stats', description: 'Get system statistics', auth: true, admin: true }
-
]
-
},
-
gdpr: {
-
name: 'GDPR Compliance',
-
description: 'GDPR compliance endpoints for data export, erasure, and consent management',
-
endpoints: [
-
{ method: 'GET', path: '/api/v1/gdpr/data-export/:user_id', description: 'Request personal data export (Article 20)', auth: true },
-
{ method: 'GET', path: '/api/v1/gdpr/data-export/download/:token', description: 'Download exported personal data', auth: true },
-
{ method: 'POST', path: '/api/v1/gdpr/data-erasure/:user_id', description: 'Request personal data erasure (Article 17)', auth: true },
-
{ method: 'POST', path: '/api/v1/gdpr/data-erasure/confirm/:token', description: 'Confirm data erasure request', auth: true },
-
{ method: 'GET', path: '/api/v1/gdpr/data-portability/:user_id', description: 'Get data portability information (Article 20)', auth: true },
-
{ method: 'GET', path: '/api/v1/gdpr/requests', description: 'List GDPR requests for user', auth: true },
-
{ method: 'GET', path: '/api/v1/gdpr/status/:user_id', description: 'Get GDPR compliance status', auth: true },
-
{ method: 'POST', path: '/api/v1/gdpr/consent/:user_id', description: 'Record user consent (Article 7)', auth: true },
-
{ method: 'DELETE', path: '/api/v1/gdpr/consent/:user_id', description: 'Withdraw user consent', auth: true },
-
{ method: 'GET', path: '/api/v1/gdpr/audit-log', description: 'Get GDPR audit log (admin only)', auth: true, admin: true }
-
]
-
}
-
}
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
module Api
-
module V1
-
class GdprController < BaseController
-
before_action :authenticate_user!
-
before_action :set_user, only: [:export_data, :request_erasure, :data_portability]
-
before_action :validate_gdpr_request, only: [:export_data, :request_erasure]
-
-
# GET /api/v1/gdpr/data-export/:user_id
-
# Export personal data for a user (GDPR Article 20 - Right to Data Portability)
-
def export_data
-
begin
-
export_request = GdprService.create_export_request(@user, current_user)
-
-
render json: {
-
success: true,
-
message: 'Personal data export request created successfully',
-
data: {
-
request_id: export_request.id,
-
token: export_request.token,
-
status: export_request.status,
-
requested_at: export_request.created_at,
-
estimated_completion: 5.minutes.from_now,
-
download_url: api_v1_gdpr_download_export_url(export_request.token)
-
}
-
}, status: :created
-
rescue => e
-
render json: {
-
success: false,
-
message: 'Failed to create export request',
-
error: e.message
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# GET /api/v1/gdpr/data-export/download/:token
-
# Download exported personal data
-
def download_export
-
export_request = PersonalDataExportRequest.find_by(token: params[:token])
-
-
unless export_request
-
render json: {
-
success: false,
-
message: 'Export request not found'
-
}, status: :not_found
-
return
-
end
-
-
unless export_request.completed?
-
render json: {
-
success: false,
-
message: 'Export is not ready yet',
-
data: {
-
status: export_request.status,
-
estimated_completion: export_request.created_at + 5.minutes
-
}
-
}, status: :accepted
-
return
-
end
-
-
unless File.exist?(export_request.file_path)
-
render json: {
-
success: false,
-
message: 'Export file not found'
-
}, status: :not_found
-
return
-
end
-
-
send_file export_request.file_path,
-
filename: "personal_data_#{export_request.email.gsub('@', '_at_')}_#{Date.today}.json",
-
type: 'application/json',
-
disposition: 'attachment'
-
rescue => e
-
render json: {
-
success: false,
-
message: 'Download failed',
-
error: e.message
-
}, status: :internal_server_error
-
end
-
-
# POST /api/v1/gdpr/data-erasure/:user_id
-
# Request data erasure (GDPR Article 17 - Right to Erasure)
-
def request_erasure
-
begin
-
erasure_request = GdprService.create_erasure_request(@user, current_user, params[:reason])
-
-
render json: {
-
success: true,
-
message: 'Data erasure request created successfully',
-
data: {
-
request_id: erasure_request.id,
-
token: erasure_request.token,
-
status: erasure_request.status,
-
requested_at: erasure_request.created_at,
-
reason: erasure_request.reason,
-
confirmation_url: api_v1_gdpr_confirm_erasure_url(erasure_request.token),
-
metadata: erasure_request.metadata
-
}
-
}, status: :created
-
rescue => e
-
render json: {
-
success: false,
-
message: 'Failed to create erasure request',
-
error: e.message
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# POST /api/v1/gdpr/data-erasure/confirm/:token
-
# Confirm data erasure request
-
def confirm_erasure
-
erasure_request = PersonalDataErasureRequest.find_by(token: params[:token])
-
-
unless erasure_request
-
render json: {
-
success: false,
-
message: 'Erasure request not found'
-
}, status: :not_found
-
return
-
end
-
-
if erasure_request.status != 'pending_confirmation'
-
render json: {
-
success: false,
-
message: 'This request has already been processed',
-
data: { status: erasure_request.status }
-
}, status: :unprocessable_entity
-
return
-
end
-
-
begin
-
GdprService.confirm_erasure_request(erasure_request, current_user)
-
-
render json: {
-
success: true,
-
message: 'Data erasure confirmed and queued for processing',
-
data: {
-
request_id: erasure_request.id,
-
status: erasure_request.status,
-
confirmed_at: erasure_request.confirmed_at,
-
estimated_completion: 10.minutes.from_now
-
}
-
}, status: :ok
-
rescue => e
-
render json: {
-
success: false,
-
message: 'Failed to confirm erasure request',
-
error: e.message
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# GET /api/v1/gdpr/data-portability/:user_id
-
# Get data portability information (GDPR Article 20)
-
def data_portability
-
begin
-
portability_data = GdprService.generate_portability_data(@user)
-
-
render json: {
-
success: true,
-
message: 'Data portability information retrieved successfully',
-
data: portability_data
-
}, status: :ok
-
rescue => e
-
render json: {
-
success: false,
-
message: 'Failed to retrieve portability data',
-
error: e.message
-
}, status: :internal_server_error
-
end
-
end
-
-
# GET /api/v1/gdpr/requests
-
# List all GDPR requests for the current user or admin
-
def requests
-
if current_user.administrator?
-
export_requests = PersonalDataExportRequest.includes(:user).recent.limit(50)
-
erasure_requests = PersonalDataErasureRequest.includes(:user).recent.limit(50)
-
else
-
export_requests = PersonalDataExportRequest.where(user: current_user).recent.limit(50)
-
erasure_requests = PersonalDataErasureRequest.where(user: current_user).recent.limit(50)
-
end
-
-
render json: {
-
success: true,
-
data: {
-
export_requests: export_requests.map do |req|
-
{
-
id: req.id,
-
user_email: req.email,
-
status: req.status,
-
requested_at: req.created_at,
-
completed_at: req.completed_at,
-
download_url: req.completed? ? api_v1_gdpr_download_export_url(req.token) : nil
-
}
-
end,
-
erasure_requests: erasure_requests.map do |req|
-
{
-
id: req.id,
-
user_email: req.email,
-
status: req.status,
-
reason: req.reason,
-
requested_at: req.created_at,
-
confirmed_at: req.confirmed_at,
-
completed_at: req.completed_at
-
}
-
end
-
}
-
}, status: :ok
-
end
-
-
# GET /api/v1/gdpr/status/:user_id
-
# Get GDPR compliance status for a user
-
def status
-
begin
-
status_data = GdprService.get_user_gdpr_status(@user)
-
-
render json: {
-
success: true,
-
data: status_data
-
}, status: :ok
-
rescue => e
-
render json: {
-
success: false,
-
message: 'Failed to retrieve GDPR status',
-
error: e.message
-
}, status: :internal_server_error
-
end
-
end
-
-
# POST /api/v1/gdpr/consent/:user_id
-
# Record user consent (GDPR Article 7)
-
def record_consent
-
begin
-
consent_data = GdprService.record_user_consent(@user, params[:consent_type], params[:consent_data])
-
-
render json: {
-
success: true,
-
message: 'Consent recorded successfully',
-
data: consent_data
-
}, status: :created
-
rescue => e
-
render json: {
-
success: false,
-
message: 'Failed to record consent',
-
error: e.message
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/gdpr/consent/:user_id
-
# Withdraw user consent
-
def withdraw_consent
-
begin
-
GdprService.withdraw_user_consent(@user, params[:consent_type])
-
-
render json: {
-
success: true,
-
message: 'Consent withdrawn successfully'
-
}, status: :ok
-
rescue => e
-
render json: {
-
success: false,
-
message: 'Failed to withdraw consent',
-
error: e.message
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# GET /api/v1/gdpr/audit-log
-
# Get GDPR audit log (admin only)
-
def audit_log
-
unless current_user.administrator?
-
render json: {
-
success: false,
-
message: 'Access denied'
-
}, status: :forbidden
-
return
-
end
-
-
begin
-
audit_data = GdprService.get_audit_log(params[:page] || 1, params[:per_page] || 50)
-
-
render json: {
-
success: true,
-
data: audit_data
-
}, status: :ok
-
rescue => e
-
render json: {
-
success: false,
-
message: 'Failed to retrieve audit log',
-
error: e.message
-
}, status: :internal_server_error
-
end
-
end
-
-
private
-
-
def set_user
-
@user = User.find(params[:user_id])
-
-
# Users can only access their own data unless they're admin
-
unless current_user.administrator? || @user == current_user
-
render json: {
-
success: false,
-
message: 'Access denied'
-
}, status: :forbidden
-
end
-
rescue ActiveRecord::RecordNotFound
-
render json: {
-
success: false,
-
message: 'User not found'
-
}, status: :not_found
-
end
-
-
def validate_gdpr_request
-
# Prevent admins from being erased
-
if params[:action] == 'request_erasure' && @user.administrator?
-
render json: {
-
success: false,
-
message: 'Cannot erase data for administrator accounts. Please change their role first.'
-
}, status: :unprocessable_entity
-
end
-
end
-
end
-
end
-
end
-
class Api::V1::ImageOptimizationController < Api::V1::BaseController
-
before_action :authenticate_user!
-
-
# GET /api/v1/image_optimization/analytics
-
def analytics
-
@stats = calculate_overview_stats
-
@recent_optimizations = ImageOptimizationLog.recent.limit(50).includes(:medium, :upload, :user)
-
@compression_level_stats = ImageOptimizationLog.compression_level_stats
-
@optimization_type_stats = ImageOptimizationLog.optimization_type_stats
-
-
render json: {
-
success: true,
-
data: {
-
overview: @stats,
-
recent_optimizations: @recent_optimizations.map(&:api_response),
-
compression_level_stats: @compression_level_stats,
-
optimization_type_stats: @optimization_type_stats
-
}
-
}
-
end
-
-
# GET /api/v1/image_optimization/report
-
def report
-
start_date = params[:start_date]&.to_date || 30.days.ago.to_date
-
end_date = params[:end_date]&.to_date || Date.current
-
-
@report = ImageOptimizationLog.generate_report(start_date, end_date)
-
-
render json: {
-
success: true,
-
data: {
-
report: @report,
-
date_range: {
-
start_date: start_date,
-
end_date: end_date
-
}
-
}
-
}
-
end
-
-
# GET /api/v1/image_optimization/failed
-
def failed
-
@failed_optimizations = ImageOptimizationLog.failed_optimizations
-
.includes(:medium, :upload, :user)
-
.page(params[:page])
-
.per(params[:per_page] || 20)
-
-
render json: {
-
success: true,
-
data: {
-
failed_optimizations: @failed_optimizations.map(&:api_response),
-
pagination: {
-
current_page: @failed_optimizations.current_page,
-
total_pages: @failed_optimizations.total_pages,
-
total_count: @failed_optimizations.total_count
-
}
-
}
-
}
-
end
-
-
# GET /api/v1/image_optimization/top_savings
-
def top_savings
-
limit = params[:limit]&.to_i || 50
-
@top_savings = ImageOptimizationLog.top_savings(limit).includes(:medium, :upload, :user)
-
-
render json: {
-
success: true,
-
data: {
-
top_savings: @top_savings.map(&:api_response)
-
}
-
}
-
end
-
-
# GET /api/v1/image_optimization/user_stats
-
def user_stats
-
@user_stats = ImageOptimizationLog.user_stats
-
@top_users = @user_stats.sort_by { |_, count| -count }.first(20)
-
-
render json: {
-
success: true,
-
data: {
-
user_stats: @user_stats,
-
top_users: @top_users
-
}
-
}
-
end
-
-
# GET /api/v1/image_optimization/compression_levels
-
def compression_levels
-
@compression_levels = ImageOptimizationService.available_compression_levels
-
@level_stats = ImageOptimizationLog.compression_level_stats
-
-
render json: {
-
success: true,
-
data: {
-
available_levels: @compression_levels,
-
usage_stats: @level_stats
-
}
-
}
-
end
-
-
# GET /api/v1/image_optimization/performance
-
def performance
-
@avg_processing_time = ImageOptimizationLog.average_processing_time
-
@avg_size_reduction = ImageOptimizationLog.average_size_reduction
-
@total_processing_time = ImageOptimizationLog.total_processing_time
-
@total_bytes_saved = ImageOptimizationLog.total_bytes_saved
-
-
render json: {
-
success: true,
-
data: {
-
average_processing_time: @avg_processing_time,
-
average_size_reduction: @avg_size_reduction,
-
total_processing_time: @total_processing_time,
-
total_bytes_saved: @total_bytes_saved,
-
total_size_saved_mb: (@total_bytes_saved / 1024.0 / 1024.0).round(2)
-
}
-
}
-
end
-
-
# POST /api/v1/image_optimization/bulk_optimize
-
def bulk_optimize
-
# Get all unoptimized images
-
unoptimized_uploads = Upload.joins(:media)
-
.where(media: { id: Medium.where.not(id: ImageOptimizationLog.select(:medium_id)) })
-
.where.not(file: nil)
-
-
if unoptimized_uploads.empty?
-
render json: {
-
success: true,
-
message: 'No unoptimized images found',
-
data: { queued_count: 0 }
-
}
-
return
-
end
-
-
# Queue optimization jobs
-
queued_count = 0
-
unoptimized_uploads.limit(100).each do |upload|
-
medium = upload.media.first
-
if medium
-
OptimizeImageJob.perform_later(
-
medium_id: medium.id,
-
optimization_type: 'bulk',
-
request_context: {
-
user_agent: request.user_agent,
-
ip_address: request.remote_ip
-
}
-
)
-
queued_count += 1
-
end
-
end
-
-
render json: {
-
success: true,
-
message: "Queued #{queued_count} images for optimization",
-
data: { queued_count: queued_count }
-
}
-
end
-
-
# POST /api/v1/image_optimization/regenerate_variants
-
def regenerate_variants
-
medium_id = params[:medium_id]
-
-
if medium_id
-
medium = Medium.find(medium_id)
-
OptimizeImageJob.perform_later(
-
medium_id: medium.id,
-
optimization_type: 'regenerate',
-
request_context: {
-
user_agent: request.user_agent,
-
ip_address: request.remote_ip
-
}
-
)
-
-
render json: {
-
success: true,
-
message: "Queued variant regeneration for medium #{medium_id}",
-
data: { medium_id: medium_id }
-
}
-
else
-
render json: {
-
success: false,
-
message: 'medium_id parameter is required'
-
}, status: 400
-
end
-
end
-
-
# DELETE /api/v1/image_optimization/clear_logs
-
def clear_logs
-
if params[:confirm] == 'yes'
-
ImageOptimizationLog.delete_all
-
render json: {
-
success: true,
-
message: 'All optimization logs have been cleared'
-
}
-
else
-
render json: {
-
success: false,
-
message: 'Log clearing cancelled. Use confirm=yes to clear logs.'
-
}, status: 400
-
end
-
end
-
-
# GET /api/v1/image_optimization/export
-
def export
-
start_date = params[:start_date]&.to_date || 30.days.ago.to_date
-
end_date = params[:end_date]&.to_date || Date.current
-
-
csv_data = ImageOptimizationLog.export_to_csv(start_date, end_date)
-
-
send_data csv_data,
-
filename: "image_optimization_export_#{start_date}_to_#{end_date}.csv",
-
type: 'text/csv'
-
end
-
-
private
-
-
def calculate_overview_stats
-
total_bytes_saved = ImageOptimizationLog.total_bytes_saved || 0
-
avg_reduction = ImageOptimizationLog.average_size_reduction || 0
-
avg_processing = ImageOptimizationLog.average_processing_time || 0
-
-
{
-
total_optimizations: ImageOptimizationLog.count,
-
successful_optimizations: ImageOptimizationLog.successful.count,
-
failed_optimizations: ImageOptimizationLog.failed.count,
-
skipped_optimizations: ImageOptimizationLog.skipped.count,
-
total_bytes_saved: total_bytes_saved,
-
total_size_saved_mb: (total_bytes_saved / 1024.0 / 1024.0).round(2),
-
average_size_reduction: avg_reduction.round(2),
-
average_processing_time: avg_processing.round(3),
-
today_optimizations: ImageOptimizationLog.today.count,
-
this_week_optimizations: ImageOptimizationLog.this_week.count,
-
this_month_optimizations: ImageOptimizationLog.this_month.count
-
}
-
end
-
end
-
# Server-Sent Events helper class
-
class SSE
-
def initialize(stream, options = {})
-
@stream = stream
-
@retry_interval = options[:retry] || 300
-
@event = options[:event]
-
end
-
-
def write(data, options = {})
-
event = options[:event] || @event
-
id = options[:id]
-
retry_interval = options[:retry] || @retry_interval
-
-
# Write event type
-
@stream.write("event: #{event}\n") if event
-
-
# Write event ID
-
@stream.write("id: #{id}\n") if id
-
-
# Write retry interval
-
@stream.write("retry: #{retry_interval}\n") if retry_interval
-
-
# Write data
-
if data.is_a?(String)
-
@stream.write("data: #{data}\n\n")
-
else
-
@stream.write("data: #{data.to_json}\n\n")
-
end
-
-
@stream.flush
-
end
-
-
def close
-
@stream.close
-
end
-
end
-
-
module Api
-
module V1
-
class McpController < BaseController
-
before_action :authenticate_api_user!, only: [:tools_call]
-
-
# POST /api/v1/mcp/session/handshake
-
def handshake
-
body = request.body.read
-
if body.blank?
-
return render_jsonrpc_error(-32700, 'Parse error', nil)
-
end
-
-
request_data = JSON.parse(body)
-
-
# Validate JSON-RPC format
-
unless request_data['jsonrpc'] == '2.0' && request_data['method'] == 'session/handshake'
-
return render_jsonrpc_error(-32600, 'Invalid Request', request_data['id'])
-
end
-
-
# Validate protocol version
-
protocol_version = request_data.dig('params', 'protocolVersion')
-
unless protocol_version == '2025-03-26'
-
return render_jsonrpc_error(-32602, 'Invalid protocol version', request_data['id'])
-
end
-
-
# Respond with server capabilities
-
response_data = {
-
jsonrpc: '2.0',
-
result: {
-
protocolVersion: '2025-03-26',
-
capabilities: ['tools', 'resources', 'prompts'],
-
serverInfo: {
-
name: 'railspress-mcp-server',
-
version: '1.0.0'
-
}
-
},
-
id: request_data['id']
-
}
-
-
render json: response_data
-
end
-
-
# GET /api/v1/mcp/tools/list
-
def tools_list
-
tools = [
-
{
-
name: 'get_posts',
-
description: 'Retrieve posts with optional filtering',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
status: { type: 'string', enum: ['published', 'draft', 'pending_review', 'scheduled', 'trash'] },
-
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
-
offset: { type: 'integer', minimum: 0, default: 0 },
-
search: { type: 'string', description: 'Search in title and content' },
-
category: { type: 'string', description: 'Filter by category slug' },
-
tag: { type: 'string', description: 'Filter by tag slug' },
-
author: { type: 'integer', description: 'Filter by author ID' },
-
date_from: { type: 'string', format: 'date' },
-
date_to: { type: 'string', format: 'date' }
-
}
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
posts: {
-
type: 'array',
-
items: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
title: { type: 'string' },
-
slug: { type: 'string' },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string' },
-
published_at: { type: 'string', format: 'date-time' },
-
created_at: { type: 'string', format: 'date-time' },
-
updated_at: { type: 'string', format: 'date-time' },
-
author: { type: 'object' },
-
categories: { type: 'array' },
-
tags: { type: 'array' },
-
meta_fields: { type: 'object' }
-
}
-
}
-
},
-
total: { type: 'integer' },
-
limit: { type: 'integer' },
-
offset: { type: 'integer' }
-
}
-
}
-
},
-
{
-
name: 'get_post',
-
description: 'Retrieve a single post by ID or slug',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer', description: 'Post ID' },
-
slug: { type: 'string', description: 'Post slug' }
-
},
-
anyOf: [
-
{ required: ['id'] },
-
{ required: ['slug'] }
-
]
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
post: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
title: { type: 'string' },
-
slug: { type: 'string' },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string' },
-
published_at: { type: 'string', format: 'date-time' },
-
created_at: { type: 'string', format: 'date-time' },
-
updated_at: { type: 'string', format: 'date-time' },
-
author: { type: 'object' },
-
categories: { type: 'array' },
-
tags: { type: 'array' },
-
meta_fields: { type: 'object' },
-
comments: { type: 'array' }
-
}
-
}
-
}
-
}
-
},
-
{
-
name: 'create_post',
-
description: 'Create a new post',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
title: { type: 'string', minLength: 1 },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string', enum: ['draft', 'published', 'pending_review', 'scheduled'], default: 'draft' },
-
published_at: { type: 'string', format: 'date-time' },
-
slug: { type: 'string' },
-
meta_title: { type: 'string' },
-
meta_description: { type: 'string' },
-
category_ids: { type: 'array', items: { type: 'integer' } },
-
tag_ids: { type: 'array', items: { type: 'integer' } },
-
meta_fields: { type: 'object' }
-
},
-
required: ['title']
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
post: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
title: { type: 'string' },
-
slug: { type: 'string' },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string' },
-
published_at: { type: 'string', format: 'date-time' },
-
created_at: { type: 'string', format: 'date-time' },
-
updated_at: { type: 'string', format: 'date-time' }
-
}
-
}
-
}
-
}
-
},
-
{
-
name: 'update_post',
-
description: 'Update an existing post',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
title: { type: 'string' },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string', enum: ['draft', 'published', 'pending_review', 'scheduled'] },
-
published_at: { type: 'string', format: 'date-time' },
-
slug: { type: 'string' },
-
meta_title: { type: 'string' },
-
meta_description: { type: 'string' },
-
category_ids: { type: 'array', items: { type: 'integer' } },
-
tag_ids: { type: 'array', items: { type: 'integer' } },
-
meta_fields: { type: 'object' }
-
},
-
required: ['id']
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
post: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
title: { type: 'string' },
-
slug: { type: 'string' },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string' },
-
published_at: { type: 'string', format: 'date-time' },
-
created_at: { type: 'string', format: 'date-time' },
-
updated_at: { type: 'string', format: 'date-time' }
-
}
-
}
-
}
-
}
-
},
-
{
-
name: 'delete_post',
-
description: 'Delete a post (move to trash)',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' }
-
},
-
required: ['id']
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
success: { type: 'boolean' },
-
message: { type: 'string' }
-
}
-
}
-
},
-
{
-
name: 'get_pages',
-
description: 'Retrieve pages with optional filtering',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
status: { type: 'string', enum: ['published', 'draft', 'pending_review', 'scheduled', 'private_page', 'trash'] },
-
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
-
offset: { type: 'integer', minimum: 0, default: 0 },
-
search: { type: 'string', description: 'Search in title and content' },
-
parent_id: { type: 'integer', description: 'Filter by parent page ID' },
-
root_only: { type: 'boolean', description: 'Only root pages' },
-
channel: { type: 'string', description: 'Filter by channel slug' }
-
}
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
pages: {
-
type: 'array',
-
items: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
title: { type: 'string' },
-
slug: { type: 'string' },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string' },
-
published_at: { type: 'string', format: 'date-time' },
-
created_at: { type: 'string', format: 'date-time' },
-
updated_at: { type: 'string', format: 'date-time' },
-
author: { type: 'object' },
-
parent: { type: 'object' },
-
children: { type: 'array' },
-
meta_fields: { type: 'object' }
-
}
-
}
-
},
-
total: { type: 'integer' },
-
limit: { type: 'integer' },
-
offset: { type: 'integer' }
-
}
-
}
-
},
-
{
-
name: 'get_page',
-
description: 'Retrieve a single page by ID or slug',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer', description: 'Page ID' },
-
slug: { type: 'string', description: 'Page slug' }
-
},
-
anyOf: [
-
{ required: ['id'] },
-
{ required: ['slug'] }
-
]
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
page: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
title: { type: 'string' },
-
slug: { type: 'string' },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string' },
-
published_at: { type: 'string', format: 'date-time' },
-
created_at: { type: 'string', format: 'date-time' },
-
updated_at: { type: 'string', format: 'date-time' },
-
author: { type: 'object' },
-
parent: { type: 'object' },
-
children: { type: 'array' },
-
meta_fields: { type: 'object' },
-
comments: { type: 'array' }
-
}
-
}
-
}
-
}
-
},
-
{
-
name: 'create_page',
-
description: 'Create a new page',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
title: { type: 'string', minLength: 1 },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string', enum: ['draft', 'published', 'pending_review', 'scheduled', 'private_page'], default: 'draft' },
-
published_at: { type: 'string', format: 'date-time' },
-
slug: { type: 'string' },
-
parent_id: { type: 'integer' },
-
meta_title: { type: 'string' },
-
meta_description: { type: 'string' },
-
meta_fields: { type: 'object' }
-
},
-
required: ['title']
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
page: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
title: { type: 'string' },
-
slug: { type: 'string' },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string' },
-
published_at: { type: 'string', format: 'date-time' },
-
created_at: { type: 'string', format: 'date-time' },
-
updated_at: { type: 'string', format: 'date-time' }
-
}
-
}
-
}
-
}
-
},
-
{
-
name: 'update_page',
-
description: 'Update an existing page',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
title: { type: 'string' },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string', enum: ['draft', 'published', 'pending_review', 'scheduled', 'private_page'] },
-
published_at: { type: 'string', format: 'date-time' },
-
slug: { type: 'string' },
-
parent_id: { type: 'integer' },
-
meta_title: { type: 'string' },
-
meta_description: { type: 'string' },
-
meta_fields: { type: 'object' }
-
},
-
required: ['id']
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
page: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
title: { type: 'string' },
-
slug: { type: 'string' },
-
content: { type: 'string' },
-
excerpt: { type: 'string' },
-
status: { type: 'string' },
-
published_at: { type: 'string', format: 'date-time' },
-
created_at: { type: 'string', format: 'date-time' },
-
updated_at: { type: 'string', format: 'date-time' }
-
}
-
}
-
}
-
}
-
},
-
{
-
name: 'delete_page',
-
description: 'Delete a page (move to trash)',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' }
-
},
-
required: ['id']
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
success: { type: 'boolean' },
-
message: { type: 'string' }
-
}
-
}
-
},
-
{
-
name: 'get_taxonomies',
-
description: 'Retrieve all taxonomies',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
hierarchical: { type: 'boolean', description: 'Filter by hierarchical type' },
-
object_types: { type: 'array', items: { type: 'string' }, description: 'Filter by object types' }
-
}
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
taxonomies: {
-
type: 'array',
-
items: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
name: { type: 'string' },
-
slug: { type: 'string' },
-
description: { type: 'string' },
-
hierarchical: { type: 'boolean' },
-
object_types: { type: 'array' },
-
term_count: { type: 'integer' },
-
settings: { type: 'object' }
-
}
-
}
-
}
-
}
-
}
-
},
-
{
-
name: 'get_terms',
-
description: 'Retrieve terms for a taxonomy',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
taxonomy: { type: 'string', description: 'Taxonomy slug (e.g., category, post_tag)' },
-
parent_id: { type: 'integer', description: 'Filter by parent term ID' },
-
root_only: { type: 'boolean', description: 'Only root terms' },
-
search: { type: 'string', description: 'Search in term names' },
-
limit: { type: 'integer', minimum: 1, maximum: 100, default: 50 },
-
offset: { type: 'integer', minimum: 0, default: 0 }
-
},
-
required: ['taxonomy']
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
terms: {
-
type: 'array',
-
items: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
name: { type: 'string' },
-
slug: { type: 'string' },
-
description: { type: 'string' },
-
count: { type: 'integer' },
-
parent_id: { type: 'integer' },
-
taxonomy: { type: 'object' },
-
children: { type: 'array' }
-
}
-
}
-
},
-
total: { type: 'integer' },
-
limit: { type: 'integer' },
-
offset: { type: 'integer' }
-
}
-
}
-
},
-
{
-
name: 'create_term',
-
description: 'Create a new term',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
name: { type: 'string', minLength: 1 },
-
taxonomy: { type: 'string', description: 'Taxonomy slug' },
-
description: { type: 'string' },
-
parent_id: { type: 'integer' },
-
slug: { type: 'string' },
-
metadata: { type: 'object' }
-
},
-
required: ['name', 'taxonomy']
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
term: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
name: { type: 'string' },
-
slug: { type: 'string' },
-
description: { type: 'string' },
-
count: { type: 'integer' },
-
parent_id: { type: 'integer' },
-
taxonomy: { type: 'object' }
-
}
-
}
-
}
-
}
-
},
-
{
-
name: 'update_term',
-
description: 'Update an existing term',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
name: { type: 'string' },
-
description: { type: 'string' },
-
parent_id: { type: 'integer' },
-
slug: { type: 'string' },
-
metadata: { type: 'object' }
-
},
-
required: ['id']
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
term: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
name: { type: 'string' },
-
slug: { type: 'string' },
-
description: { type: 'string' },
-
count: { type: 'integer' },
-
parent_id: { type: 'integer' },
-
taxonomy: { type: 'object' }
-
}
-
}
-
}
-
}
-
},
-
{
-
name: 'delete_term',
-
description: 'Delete a term',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' }
-
},
-
required: ['id']
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
success: { type: 'boolean' },
-
message: { type: 'string' }
-
}
-
}
-
},
-
{
-
name: 'get_content_types',
-
description: 'Retrieve all content types',
-
inputSchema: {
-
type: 'object',
-
properties: {}
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
content_types: {
-
type: 'array',
-
items: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
name: { type: 'string' },
-
slug: { type: 'string' },
-
description: { type: 'string' },
-
icon: { type: 'string' },
-
supports: { type: 'array' },
-
labels: { type: 'object' },
-
capabilities: { type: 'object' },
-
settings: { type: 'object' }
-
}
-
}
-
}
-
}
-
}
-
},
-
{
-
name: 'get_media',
-
description: 'Retrieve media files',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
-
offset: { type: 'integer', minimum: 0, default: 0 },
-
search: { type: 'string', description: 'Search in filename and title' },
-
mime_type: { type: 'string', description: 'Filter by MIME type' },
-
uploaded_by: { type: 'integer', description: 'Filter by uploader ID' },
-
date_from: { type: 'string', format: 'date' },
-
date_to: { type: 'string', format: 'date' }
-
}
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
media: {
-
type: 'array',
-
items: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
filename: { type: 'string' },
-
title: { type: 'string' },
-
alt_text: { type: 'string' },
-
caption: { type: 'string' },
-
description: { type: 'string' },
-
mime_type: { type: 'string' },
-
file_size: { type: 'integer' },
-
url: { type: 'string' },
-
thumbnail_url: { type: 'string' },
-
uploaded_at: { type: 'string', format: 'date-time' },
-
uploaded_by: { type: 'object' }
-
}
-
}
-
},
-
total: { type: 'integer' },
-
limit: { type: 'integer' },
-
offset: { type: 'integer' }
-
}
-
}
-
},
-
{
-
name: 'upload_media',
-
description: 'Upload a media file',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
file: { type: 'string', description: 'Base64 encoded file data' },
-
filename: { type: 'string' },
-
title: { type: 'string' },
-
alt_text: { type: 'string' },
-
caption: { type: 'string' },
-
description: { type: 'string' }
-
},
-
required: ['file', 'filename']
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
media: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
filename: { type: 'string' },
-
title: { type: 'string' },
-
alt_text: { type: 'string' },
-
caption: { type: 'string' },
-
description: { type: 'string' },
-
mime_type: { type: 'string' },
-
file_size: { type: 'integer' },
-
url: { type: 'string' },
-
thumbnail_url: { type: 'string' },
-
uploaded_at: { type: 'string', format: 'date-time' }
-
}
-
}
-
}
-
}
-
},
-
{
-
name: 'get_users',
-
description: 'Retrieve users',
-
inputSchema: {
-
type: 'object',
-
properties: {
-
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
-
offset: { type: 'integer', minimum: 0, default: 0 },
-
search: { type: 'string', description: 'Search in name and email' },
-
role: { type: 'string', description: 'Filter by role' },
-
status: { type: 'string', enum: ['active', 'inactive'] }
-
}
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
users: {
-
type: 'array',
-
items: {
-
type: 'object',
-
properties: {
-
id: { type: 'integer' },
-
name: { type: 'string' },
-
email: { type: 'string' },
-
role: { type: 'string' },
-
status: { type: 'string' },
-
created_at: { type: 'string', format: 'date-time' },
-
last_login_at: { type: 'string', format: 'date-time' }
-
}
-
}
-
},
-
total: { type: 'integer' },
-
limit: { type: 'integer' },
-
offset: { type: 'integer' }
-
}
-
}
-
},
-
{
-
name: 'get_system_info',
-
description: 'Get system information and statistics',
-
inputSchema: {
-
type: 'object',
-
properties: {}
-
},
-
outputSchema: {
-
type: 'object',
-
properties: {
-
system: {
-
type: 'object',
-
properties: {
-
name: { type: 'string' },
-
version: { type: 'string' },
-
rails_version: { type: 'string' },
-
ruby_version: { type: 'string' },
-
environment: { type: 'string' },
-
statistics: {
-
type: 'object',
-
properties: {
-
posts_count: { type: 'integer' },
-
pages_count: { type: 'integer' },
-
users_count: { type: 'integer' },
-
media_count: { type: 'integer' },
-
comments_count: { type: 'integer' }
-
}
-
}
-
}
-
}
-
}
-
}
-
}
-
]
-
-
render_jsonrpc_success({ tools: tools })
-
end
-
-
# POST /api/v1/mcp/tools/call
-
def tools_call
-
body = request.body.read
-
if body.blank?
-
return render_jsonrpc_error(-32700, 'Parse error', nil)
-
end
-
-
request_data = JSON.parse(body)
-
-
unless request_data['jsonrpc'] == '2.0' && request_data['method'] == 'tools/call'
-
return render_jsonrpc_error(-32600, 'Invalid Request', request_data['id'])
-
end
-
-
tool_name = request_data.dig('params', 'name')
-
arguments = request_data.dig('params', 'arguments') || {}
-
-
result = execute_tool(tool_name, arguments)
-
-
if result[:success]
-
render_jsonrpc_success({
-
content: [
-
{
-
type: 'output',
-
data: result[:data]
-
}
-
]
-
}, request_data['id'])
-
else
-
render_jsonrpc_error(-32603, result[:error], request_data['id'])
-
end
-
rescue => e
-
Rails.logger.error "MCP Tool Error: #{e.message}"
-
render_jsonrpc_error(-32603, "Internal error: #{e.message}", request_data['id'])
-
end
-
-
# GET /api/v1/mcp/tools/stream
-
def tools_stream
-
response.headers['Content-Type'] = 'text/event-stream'
-
response.headers['Cache-Control'] = 'no-cache'
-
response.headers['Connection'] = 'keep-alive'
-
-
tool_name = params[:tool]
-
arguments = JSON.parse(params[:arguments] || '{}')
-
-
sse = SSE.new(response.stream, retry: 300, event: "message")
-
-
begin
-
# Send initial progress
-
sse.write({ progress: 0.1, message: "Starting #{tool_name}" }, event: 'tools/update')
-
-
# Execute tool with streaming updates
-
result = execute_tool_with_streaming(tool_name, arguments) do |progress, partial_data|
-
sse.write({
-
tool: tool_name,
-
progress: progress,
-
partial: partial_data
-
}, event: 'tools/update')
-
end
-
-
# Send final result
-
sse.write({
-
tool: tool_name,
-
content: [
-
{
-
type: 'output',
-
data: result[:data]
-
}
-
]
-
}, event: 'tools/complete')
-
-
rescue => e
-
sse.write({
-
tool: tool_name,
-
error: e.message
-
}, event: 'tools/error')
-
ensure
-
sse.close
-
end
-
end
-
-
# GET /api/v1/mcp/resources/list
-
def resources_list
-
resources = [
-
{
-
uri: 'railspress://posts',
-
name: 'Posts Collection',
-
description: 'All posts in the system',
-
mimeType: 'application/json'
-
},
-
{
-
uri: 'railspress://pages',
-
name: 'Pages Collection',
-
description: 'All pages in the system',
-
mimeType: 'application/json'
-
},
-
{
-
uri: 'railspress://taxonomies',
-
name: 'Taxonomies Collection',
-
description: 'All taxonomies in the system',
-
mimeType: 'application/json'
-
},
-
{
-
uri: 'railspress://terms',
-
name: 'Terms Collection',
-
description: 'All terms in the system',
-
mimeType: 'application/json'
-
},
-
{
-
uri: 'railspress://media',
-
name: 'Media Collection',
-
description: 'All media files in the system',
-
mimeType: 'application/json'
-
},
-
{
-
uri: 'railspress://users',
-
name: 'Users Collection',
-
description: 'All users in the system',
-
mimeType: 'application/json'
-
},
-
{
-
uri: 'railspress://content-types',
-
name: 'Content Types Collection',
-
description: 'All content types in the system',
-
mimeType: 'application/json'
-
}
-
]
-
-
render_jsonrpc_success({ resources: resources })
-
end
-
-
# GET /api/v1/mcp/prompts/list
-
def prompts_list
-
prompts = [
-
{
-
name: 'seo_optimize',
-
description: 'Optimize content for SEO',
-
arguments: [
-
{
-
name: 'content',
-
description: 'The content to optimize',
-
required: true
-
},
-
{
-
name: 'target_keywords',
-
description: 'Target keywords for SEO',
-
required: false
-
},
-
{
-
name: 'content_type',
-
description: 'Type of content (post, page)',
-
required: false
-
}
-
]
-
},
-
{
-
name: 'content_summarize',
-
description: 'Summarize content',
-
arguments: [
-
{
-
name: 'content',
-
description: 'The content to summarize',
-
required: true
-
},
-
{
-
name: 'max_length',
-
description: 'Maximum length of summary',
-
required: false
-
}
-
]
-
},
-
{
-
name: 'content_generate',
-
description: 'Generate content based on topic',
-
arguments: [
-
{
-
name: 'topic',
-
description: 'Topic to generate content about',
-
required: true
-
},
-
{
-
name: 'content_type',
-
description: 'Type of content to generate',
-
required: false
-
},
-
{
-
name: 'tone',
-
description: 'Tone of the content',
-
required: false
-
},
-
{
-
name: 'length',
-
description: 'Desired length of content',
-
required: false
-
}
-
]
-
},
-
{
-
name: 'meta_description_generate',
-
description: 'Generate meta description for content',
-
arguments: [
-
{
-
name: 'title',
-
description: 'Content title',
-
required: true
-
},
-
{
-
name: 'content',
-
description: 'Content body',
-
required: true
-
},
-
{
-
name: 'keywords',
-
description: 'Target keywords',
-
required: false
-
}
-
]
-
}
-
]
-
-
render_jsonrpc_success({ prompts: prompts })
-
end
-
-
private
-
-
def execute_tool(tool_name, arguments)
-
case tool_name
-
when 'get_posts'
-
execute_get_posts(arguments)
-
when 'get_post'
-
execute_get_post(arguments)
-
when 'create_post'
-
execute_create_post(arguments)
-
when 'update_post'
-
execute_update_post(arguments)
-
when 'delete_post'
-
execute_delete_post(arguments)
-
when 'get_pages'
-
execute_get_pages(arguments)
-
when 'get_page'
-
execute_get_page(arguments)
-
when 'create_page'
-
execute_create_page(arguments)
-
when 'update_page'
-
execute_update_page(arguments)
-
when 'delete_page'
-
execute_delete_page(arguments)
-
when 'get_taxonomies'
-
execute_get_taxonomies(arguments)
-
when 'get_terms'
-
execute_get_terms(arguments)
-
when 'create_term'
-
execute_create_term(arguments)
-
when 'update_term'
-
execute_update_term(arguments)
-
when 'delete_term'
-
execute_delete_term(arguments)
-
when 'get_content_types'
-
execute_get_content_types(arguments)
-
when 'get_media'
-
execute_get_media(arguments)
-
when 'upload_media'
-
execute_upload_media(arguments)
-
when 'get_users'
-
execute_get_users(arguments)
-
when 'get_system_info'
-
execute_get_system_info(arguments)
-
else
-
{ success: false, error: "Unknown tool: #{tool_name}" }
-
end
-
end
-
-
def execute_tool_with_streaming(tool_name, arguments, &block)
-
case tool_name
-
when 'get_posts'
-
execute_get_posts_streaming(arguments, &block)
-
when 'get_media'
-
execute_get_media_streaming(arguments, &block)
-
else
-
# For tools that don't support streaming, execute normally
-
result = execute_tool(tool_name, arguments)
-
yield(0.5, { message: "Processing #{tool_name}" })
-
yield(1.0, result[:data])
-
result
-
end
-
end
-
-
# Tool implementations
-
def execute_get_posts(arguments)
-
posts = Post.all
-
-
# Apply filters
-
posts = posts.where(status: arguments['status']) if arguments['status'].present?
-
posts = posts.search_full_text(arguments['search']) if arguments['search'].present?
-
-
if arguments['category'].present?
-
category_term = Term.for_taxonomy('category').find_by(slug: arguments['category'])
-
posts = posts.joins(:term_relationships).where(term_relationships: { term_id: category_term.id }) if category_term
-
end
-
-
if arguments['tag'].present?
-
tag_term = Term.for_taxonomy('post_tag').find_by(slug: arguments['tag'])
-
posts = posts.joins(:term_relationships).where(term_relationships: { term_id: tag_term.id }) if tag_term
-
end
-
-
posts = posts.where(user_id: arguments['author']) if arguments['author'].present?
-
-
if arguments['date_from'].present?
-
posts = posts.where('published_at >= ?', Date.parse(arguments['date_from']))
-
end
-
-
if arguments['date_to'].present?
-
posts = posts.where('published_at <= ?', Date.parse(arguments['date_to']))
-
end
-
-
# Pagination
-
limit = arguments['limit'] || 20
-
offset = arguments['offset'] || 0
-
total = posts.count
-
posts = posts.limit(limit).offset(offset).order(created_at: :desc)
-
-
{
-
success: true,
-
data: {
-
posts: posts.map { |post| serialize_post(post) },
-
total: total,
-
limit: limit,
-
offset: offset
-
}
-
}
-
end
-
-
def execute_get_posts_streaming(arguments, &block)
-
yield(0.1, { message: "Fetching posts..." })
-
-
posts = Post.all
-
yield(0.3, { message: "Applying filters..." })
-
-
# Apply same filters as execute_get_posts
-
posts = posts.where(status: arguments['status']) if arguments['status'].present?
-
posts = posts.search_full_text(arguments['search']) if arguments['search'].present?
-
-
yield(0.6, { message: "Counting total..." })
-
total = posts.count
-
-
yield(0.8, { message: "Serializing data..." })
-
limit = arguments['limit'] || 20
-
offset = arguments['offset'] || 0
-
posts = posts.limit(limit).offset(offset).order(created_at: :desc)
-
-
{
-
success: true,
-
data: {
-
posts: posts.map { |post| serialize_post(post) },
-
total: total,
-
limit: limit,
-
offset: offset
-
}
-
}
-
end
-
-
def execute_get_post(arguments)
-
if arguments['id'].present?
-
post = Post.find(arguments['id'])
-
elsif arguments['slug'].present?
-
post = Post.find_by(slug: arguments['slug'])
-
else
-
return { success: false, error: 'Either id or slug must be provided' }
-
end
-
-
unless post
-
return { success: false, error: 'Post not found' }
-
end
-
-
{
-
success: true,
-
data: {
-
post: serialize_post(post, detailed: true)
-
}
-
}
-
end
-
-
def execute_create_post(arguments)
-
unless current_api_user&.can_create_posts?
-
return { success: false, error: 'You do not have permission to create posts' }
-
end
-
-
post = current_api_user.posts.build(
-
title: arguments['title'],
-
content: arguments['content'],
-
excerpt: arguments['excerpt'],
-
status: arguments['status'] || 'draft',
-
slug: arguments['slug'],
-
meta_title: arguments['meta_title'],
-
meta_description: arguments['meta_description']
-
)
-
-
if arguments['published_at'].present?
-
post.published_at = Time.parse(arguments['published_at'])
-
end
-
-
if post.save
-
# Handle categories and tags
-
if arguments['category_ids'].present?
-
post.category_ids = arguments['category_ids']
-
end
-
-
if arguments['tag_ids'].present?
-
post.tag_ids = arguments['tag_ids']
-
end
-
-
# Handle meta fields
-
if arguments['meta_fields'].present?
-
arguments['meta_fields'].each do |key, value|
-
post.set_meta(key, value)
-
end
-
end
-
-
{
-
success: true,
-
data: {
-
post: serialize_post(post)
-
}
-
}
-
else
-
{
-
success: false,
-
error: post.errors.full_messages.join(', ')
-
}
-
end
-
end
-
-
def execute_update_post(arguments)
-
post = Post.find(arguments['id'])
-
-
unless current_api_user&.can_edit_others_posts? || (current_api_user&.id == post.user_id)
-
return { success: false, error: 'You do not have permission to edit this post' }
-
end
-
-
update_params = arguments.except('id', 'category_ids', 'tag_ids', 'meta_fields')
-
-
if arguments['published_at'].present?
-
update_params['published_at'] = Time.parse(arguments['published_at'])
-
end
-
-
if post.update(update_params)
-
# Handle categories and tags
-
if arguments['category_ids'].present?
-
post.category_ids = arguments['category_ids']
-
end
-
-
if arguments['tag_ids'].present?
-
post.tag_ids = arguments['tag_ids']
-
end
-
-
# Handle meta fields
-
if arguments['meta_fields'].present?
-
arguments['meta_fields'].each do |key, value|
-
post.set_meta(key, value)
-
end
-
end
-
-
{
-
success: true,
-
data: {
-
post: serialize_post(post)
-
}
-
}
-
else
-
{
-
success: false,
-
error: post.errors.full_messages.join(', ')
-
}
-
end
-
end
-
-
def execute_delete_post(arguments)
-
post = Post.find(arguments['id'])
-
-
unless current_api_user&.can_delete_posts? || (current_api_user&.id == post.user_id)
-
return { success: false, error: 'You do not have permission to delete this post' }
-
end
-
-
post.discard
-
-
{
-
success: true,
-
data: {
-
success: true,
-
message: 'Post moved to trash'
-
}
-
}
-
end
-
-
def execute_get_pages(arguments)
-
pages = Page.all
-
-
# Apply filters
-
pages = pages.where(status: arguments['status']) if arguments['status'].present?
-
pages = pages.where(parent_id: arguments['parent_id']) if arguments['parent_id'].present?
-
pages = pages.root_pages if arguments['root_only'] == true
-
pages = pages.search_full_text(arguments['search']) if arguments['search'].present?
-
-
if arguments['channel'].present?
-
channel = Channel.find_by(slug: arguments['channel'])
-
if channel
-
pages = pages.left_joins(:channels)
-
.where('channels.id = ? OR channels.id IS NULL', channel.id)
-
end
-
end
-
-
# Pagination
-
limit = arguments['limit'] || 20
-
offset = arguments['offset'] || 0
-
total = pages.count
-
pages = pages.limit(limit).offset(offset).order(order: :asc, created_at: :desc)
-
-
{
-
success: true,
-
data: {
-
pages: pages.map { |page| serialize_page(page) },
-
total: total,
-
limit: limit,
-
offset: offset
-
}
-
}
-
end
-
-
def execute_get_page(arguments)
-
if arguments['id'].present?
-
page = Page.find(arguments['id'])
-
elsif arguments['slug'].present?
-
page = Page.find_by(slug: arguments['slug'])
-
else
-
return { success: false, error: 'Either id or slug must be provided' }
-
end
-
-
unless page
-
return { success: false, error: 'Page not found' }
-
end
-
-
{
-
success: true,
-
data: {
-
page: serialize_page(page, detailed: true)
-
}
-
}
-
end
-
-
def execute_create_page(arguments)
-
unless current_api_user&.can_create_pages?
-
return { success: false, error: 'You do not have permission to create pages' }
-
end
-
-
page = current_api_user.pages.build(
-
title: arguments['title'],
-
content: arguments['content'],
-
excerpt: arguments['excerpt'],
-
status: arguments['status'] || 'draft',
-
slug: arguments['slug'],
-
parent_id: arguments['parent_id'],
-
meta_title: arguments['meta_title'],
-
meta_description: arguments['meta_description']
-
)
-
-
if arguments['published_at'].present?
-
page.published_at = Time.parse(arguments['published_at'])
-
end
-
-
if page.save
-
# Handle meta fields
-
if arguments['meta_fields'].present?
-
arguments['meta_fields'].each do |key, value|
-
page.set_meta(key, value)
-
end
-
end
-
-
{
-
success: true,
-
data: {
-
page: serialize_page(page)
-
}
-
}
-
else
-
{
-
success: false,
-
error: page.errors.full_messages.join(', ')
-
}
-
end
-
end
-
-
def execute_update_page(arguments)
-
page = Page.find(arguments['id'])
-
-
unless current_api_user&.can_edit_others_posts? || (current_api_user&.id == page.user_id)
-
return { success: false, error: 'You do not have permission to edit this page' }
-
end
-
-
update_params = arguments.except('id', 'meta_fields')
-
-
if arguments['published_at'].present?
-
update_params['published_at'] = Time.parse(arguments['published_at'])
-
end
-
-
if page.update(update_params)
-
# Handle meta fields
-
if arguments['meta_fields'].present?
-
arguments['meta_fields'].each do |key, value|
-
page.set_meta(key, value)
-
end
-
end
-
-
{
-
success: true,
-
data: {
-
page: serialize_page(page)
-
}
-
}
-
else
-
{
-
success: false,
-
error: page.errors.full_messages.join(', ')
-
}
-
end
-
end
-
-
def execute_delete_page(arguments)
-
page = Page.find(arguments['id'])
-
-
unless current_api_user&.can_delete_posts? || (current_api_user&.id == page.user_id)
-
return { success: false, error: 'You do not have permission to delete this page' }
-
end
-
-
page.discard
-
-
{
-
success: true,
-
data: {
-
success: true,
-
message: 'Page moved to trash'
-
}
-
}
-
end
-
-
def execute_get_taxonomies(arguments)
-
taxonomies = Taxonomy.all
-
-
taxonomies = taxonomies.where(hierarchical: arguments['hierarchical']) if arguments['hierarchical'].present?
-
-
if arguments['object_types'].present?
-
object_types_filter = arguments['object_types'].map { |type| "%#{type}%" }
-
taxonomies = taxonomies.where(object_types.map { |type| "object_types LIKE ?" }.join(' OR '), *object_types_filter)
-
end
-
-
{
-
success: true,
-
data: {
-
taxonomies: taxonomies.map { |taxonomy| serialize_taxonomy(taxonomy) }
-
}
-
}
-
end
-
-
def execute_get_terms(arguments)
-
taxonomy = Taxonomy.find_by(slug: arguments['taxonomy'])
-
unless taxonomy
-
return { success: false, error: 'Taxonomy not found' }
-
end
-
-
terms = taxonomy.terms
-
-
# Apply filters
-
terms = terms.where(parent_id: arguments['parent_id']) if arguments['parent_id'].present?
-
terms = terms.root_terms if arguments['root_only'] == true
-
terms = terms.where('name LIKE ?', "%#{arguments['search']}%") if arguments['search'].present?
-
-
# Pagination
-
limit = arguments['limit'] || 50
-
offset = arguments['offset'] || 0
-
total = terms.count
-
terms = terms.limit(limit).offset(offset).ordered
-
-
{
-
success: true,
-
data: {
-
terms: terms.map { |term| serialize_term(term) },
-
total: total,
-
limit: limit,
-
offset: offset
-
}
-
}
-
end
-
-
def execute_create_term(arguments)
-
unless current_api_user&.can_edit_others_posts?
-
return { success: false, error: 'You do not have permission to create terms' }
-
end
-
-
taxonomy = Taxonomy.find_by(slug: arguments['taxonomy'])
-
unless taxonomy
-
return { success: false, error: 'Taxonomy not found' }
-
end
-
-
term = taxonomy.terms.build(
-
name: arguments['name'],
-
description: arguments['description'],
-
parent_id: arguments['parent_id'],
-
slug: arguments['slug'],
-
metadata: arguments['metadata'] || {}
-
)
-
-
if term.save
-
{
-
success: true,
-
data: {
-
term: serialize_term(term)
-
}
-
}
-
else
-
{
-
success: false,
-
error: term.errors.full_messages.join(', ')
-
}
-
end
-
end
-
-
def execute_update_term(arguments)
-
unless current_api_user&.can_edit_others_posts?
-
return { success: false, error: 'You do not have permission to edit terms' }
-
end
-
-
term = Term.find(arguments['id'])
-
-
update_params = arguments.except('id')
-
-
if term.update(update_params)
-
{
-
success: true,
-
data: {
-
term: serialize_term(term)
-
}
-
}
-
else
-
{
-
success: false,
-
error: term.errors.full_messages.join(', ')
-
}
-
end
-
end
-
-
def execute_delete_term(arguments)
-
unless current_api_user&.administrator?
-
return { success: false, error: 'You do not have permission to delete terms' }
-
end
-
-
term = Term.find(arguments['id'])
-
term.destroy
-
-
{
-
success: true,
-
data: {
-
success: true,
-
message: 'Term deleted'
-
}
-
}
-
end
-
-
def execute_get_content_types(arguments)
-
content_types = ContentType.all
-
-
{
-
success: true,
-
data: {
-
content_types: content_types.map { |ct| serialize_content_type(ct) }
-
}
-
}
-
end
-
-
def execute_get_media(arguments)
-
media = Medium.all
-
-
# Apply filters
-
media = media.where('filename LIKE ? OR title LIKE ?', "%#{arguments['search']}%", "%#{arguments['search']}%") if arguments['search'].present?
-
media = media.where(mime_type: arguments['mime_type']) if arguments['mime_type'].present?
-
media = media.where(uploaded_by_id: arguments['uploaded_by']) if arguments['uploaded_by'].present?
-
-
if arguments['date_from'].present?
-
media = media.where('created_at >= ?', Date.parse(arguments['date_from']))
-
end
-
-
if arguments['date_to'].present?
-
media = media.where('created_at <= ?', Date.parse(arguments['date_to']))
-
end
-
-
# Pagination
-
limit = arguments['limit'] || 20
-
offset = arguments['offset'] || 0
-
total = media.count
-
media = media.limit(limit).offset(offset).order(created_at: :desc)
-
-
{
-
success: true,
-
data: {
-
media: media.map { |m| serialize_media(m) },
-
total: total,
-
limit: limit,
-
offset: offset
-
}
-
}
-
end
-
-
def execute_get_media_streaming(arguments, &block)
-
yield(0.1, { message: "Fetching media..." })
-
-
media = Medium.all
-
yield(0.3, { message: "Applying filters..." })
-
-
# Apply same filters as execute_get_media
-
media = media.where('filename LIKE ? OR title LIKE ?', "%#{arguments['search']}%", "%#{arguments['search']}%") if arguments['search'].present?
-
media = media.where(mime_type: arguments['mime_type']) if arguments['mime_type'].present?
-
media = media.where(uploaded_by_id: arguments['uploaded_by']) if arguments['uploaded_by'].present?
-
-
yield(0.6, { message: "Counting total..." })
-
total = media.count
-
-
yield(0.8, { message: "Serializing data..." })
-
limit = arguments['limit'] || 20
-
offset = arguments['offset'] || 0
-
media = media.limit(limit).offset(offset).order(created_at: :desc)
-
-
{
-
success: true,
-
data: {
-
media: media.map { |m| serialize_media(m) },
-
total: total,
-
limit: limit,
-
offset: offset
-
}
-
}
-
end
-
-
def execute_upload_media(arguments)
-
unless current_api_user&.can_upload_files?
-
return { success: false, error: 'You do not have permission to upload media' }
-
end
-
-
# Decode base64 file
-
file_data = Base64.decode64(arguments['file'])
-
filename = arguments['filename']
-
-
# Create temporary file
-
temp_file = Tempfile.new([filename, File.extname(filename)])
-
temp_file.binmode
-
temp_file.write(file_data)
-
temp_file.rewind
-
-
# Create media record
-
media = Medium.new(
-
filename: filename,
-
title: arguments['title'] || filename,
-
alt_text: arguments['alt_text'],
-
caption: arguments['caption'],
-
description: arguments['description'],
-
uploaded_by: current_api_user
-
)
-
-
# Attach file
-
media.file.attach(
-
io: temp_file,
-
filename: filename,
-
content_type: MIME::Types.type_for(filename).first&.content_type || 'application/octet-stream'
-
)
-
-
if media.save
-
temp_file.close
-
temp_file.unlink
-
-
{
-
success: true,
-
data: {
-
media: serialize_media(media)
-
}
-
}
-
else
-
temp_file.close
-
temp_file.unlink
-
-
{
-
success: false,
-
error: media.errors.full_messages.join(', ')
-
}
-
end
-
end
-
-
def execute_get_users(arguments)
-
users = User.all
-
-
# Apply filters
-
users = users.where('name LIKE ? OR email LIKE ?', "%#{arguments['search']}%", "%#{arguments['search']}%") if arguments['search'].present?
-
users = users.where(role: arguments['role']) if arguments['role'].present?
-
users = users.where(active: arguments['status'] == 'active') if arguments['status'].present?
-
-
# Pagination
-
limit = arguments['limit'] || 20
-
offset = arguments['offset'] || 0
-
total = users.count
-
users = users.limit(limit).offset(offset).order(created_at: :desc)
-
-
{
-
success: true,
-
data: {
-
users: users.map { |user| serialize_user(user) },
-
total: total,
-
limit: limit,
-
offset: offset
-
}
-
}
-
end
-
-
def execute_get_system_info(arguments)
-
{
-
success: true,
-
data: {
-
system: {
-
name: 'RailsPress API',
-
version: 'v1',
-
rails_version: Rails.version,
-
ruby_version: RUBY_VERSION,
-
environment: Rails.env,
-
statistics: {
-
posts_count: Post.count,
-
pages_count: Page.count,
-
users_count: User.count,
-
media_count: Medium.count,
-
comments_count: Comment.count
-
}
-
}
-
}
-
}
-
end
-
-
# Serialization methods
-
def serialize_post(post, detailed: false)
-
data = {
-
id: post.id,
-
title: post.title,
-
slug: post.slug,
-
content: post.content.to_s,
-
excerpt: post.excerpt,
-
status: post.status,
-
published_at: post.published_at&.iso8601,
-
created_at: post.created_at.iso8601,
-
updated_at: post.updated_at.iso8601,
-
author: {
-
id: post.user.id,
-
name: post.user.name,
-
email: post.user.email
-
},
-
categories: post.categories.map { |cat| { id: cat.id, name: cat.name, slug: cat.slug } },
-
tags: post.tags.map { |tag| { id: tag.id, name: tag.name, slug: tag.slug } },
-
meta_fields: post.meta_fields.map { |mf| { key: mf.key, value: mf.value } }.index_by { |mf| mf[:key] }
-
}
-
-
if detailed
-
data[:comments] = post.comments.map { |comment| serialize_comment(comment) }
-
end
-
-
data
-
end
-
-
def serialize_page(page, detailed: false)
-
data = {
-
id: page.id,
-
title: page.title,
-
slug: page.slug,
-
content: page.content.to_s,
-
excerpt: page.excerpt,
-
status: page.status,
-
published_at: page.published_at&.iso8601,
-
created_at: page.created_at.iso8601,
-
updated_at: page.updated_at.iso8601,
-
author: {
-
id: page.user.id,
-
name: page.user.name,
-
email: page.user.email
-
},
-
parent: page.parent ? { id: page.parent.id, title: page.parent.title, slug: page.parent.slug } : nil,
-
children: page.children.map { |child| { id: child.id, title: child.title, slug: child.slug } },
-
meta_fields: page.meta_fields.map { |mf| { key: mf.key, value: mf.value } }.index_by { |mf| mf[:key] }
-
}
-
-
if detailed
-
data[:comments] = page.comments.map { |comment| serialize_comment(comment) }
-
end
-
-
data
-
end
-
-
def serialize_taxonomy(taxonomy)
-
{
-
id: taxonomy.id,
-
name: taxonomy.name,
-
slug: taxonomy.slug,
-
description: taxonomy.description,
-
hierarchical: taxonomy.hierarchical,
-
object_types: taxonomy.object_types,
-
term_count: taxonomy.term_count,
-
settings: taxonomy.settings
-
}
-
end
-
-
def serialize_term(term)
-
{
-
id: term.id,
-
name: term.name,
-
slug: term.slug,
-
description: term.description,
-
count: term.count,
-
parent_id: term.parent_id,
-
taxonomy: {
-
id: term.taxonomy.id,
-
name: term.taxonomy.name,
-
slug: term.taxonomy.slug
-
},
-
children: term.children.map { |child| { id: child.id, name: child.name, slug: child.slug } }
-
}
-
end
-
-
def serialize_content_type(content_type)
-
{
-
id: content_type.id,
-
name: content_type.name,
-
slug: content_type.slug,
-
description: content_type.description,
-
icon: content_type.icon,
-
supports: content_type.supports,
-
labels: content_type.labels,
-
capabilities: content_type.capabilities,
-
settings: content_type.settings
-
}
-
end
-
-
def serialize_media(media)
-
{
-
id: media.id,
-
filename: media.filename,
-
title: media.title,
-
alt_text: media.alt_text,
-
caption: media.caption,
-
description: media.description,
-
mime_type: media.mime_type,
-
file_size: media.file_size,
-
url: media.file.url,
-
thumbnail_url: media.file.attached? ? media.file.variant(resize_to_limit: [300, 300]).processed.url : nil,
-
uploaded_at: media.created_at.iso8601,
-
uploaded_by: {
-
id: media.uploaded_by.id,
-
name: media.uploaded_by.name,
-
email: media.uploaded_by.email
-
}
-
}
-
end
-
-
def serialize_user(user)
-
{
-
id: user.id,
-
name: user.name,
-
email: user.email,
-
role: user.role,
-
status: user.active? ? 'active' : 'inactive',
-
created_at: user.created_at.iso8601,
-
last_login_at: user.last_sign_in_at&.iso8601
-
}
-
end
-
-
def serialize_comment(comment)
-
{
-
id: comment.id,
-
content: comment.content,
-
author_name: comment.author_name,
-
author_email: comment.author_email,
-
author_url: comment.author_url,
-
status: comment.status,
-
created_at: comment.created_at.iso8601,
-
updated_at: comment.updated_at.iso8601
-
}
-
end
-
-
def render_jsonrpc_success(data, id = nil)
-
response_data = {
-
jsonrpc: '2.0',
-
result: data,
-
id: id
-
}
-
render json: response_data
-
end
-
-
def render_jsonrpc_error(code, message, id = nil)
-
response_data = {
-
jsonrpc: '2.0',
-
error: {
-
code: code,
-
message: message
-
},
-
id: id
-
}
-
render json: response_data, status: :bad_request
-
end
-
-
def authenticate_api_user!
-
# Check for API key authentication
-
api_key = request.headers['Authorization']&.gsub(/^Bearer /, '') || params[:api_key]
-
-
if api_key.blank?
-
render json: {
-
success: false,
-
error: 'API key required',
-
code: 'MISSING_API_KEY'
-
}, status: :unauthorized
-
return
-
end
-
-
@api_user = User.find_by(api_key: api_key)
-
-
unless @api_user
-
render json: {
-
success: false,
-
error: 'Invalid API key',
-
code: 'INVALID_API_KEY'
-
}, status: :unauthorized
-
return
-
end
-
-
# Check if user is active
-
unless @api_user.active?
-
render json: {
-
success: false,
-
error: 'User account is inactive',
-
code: 'INACTIVE_USER'
-
}, status: :forbidden
-
return
-
end
-
end
-
end
-
end
-
end
-
module Api
-
module V1
-
class MediaController < BaseController
-
before_action :set_medium, only: [:show, :update, :destroy]
-
-
# GET /api/v1/media
-
def index
-
media = Medium.all
-
-
# Filter by type
-
# media = media.by_type(params[:type]) if params[:type].present? # Temporarily disabled
-
-
# Filter by channel
-
if params[:channel].present?
-
channel = Channel.find_by(slug: params[:channel])
-
if channel
-
# Get media assigned to this channel or global media (no channel assignment)
-
media = media.left_joins(:channels)
-
.where('channels.id = ? OR channels.id IS NULL', channel.id)
-
-
# Apply channel exclusions
-
excluded_media_ids = channel.channel_overrides
-
.exclusions
-
.enabled
-
.where(resource_type: 'Medium')
-
.pluck(:resource_id)
-
media = media.where.not(id: excluded_media_ids) if excluded_media_ids.any?
-
-
@current_channel = channel
-
end
-
end
-
-
# Only published for non-authenticated or non-admin users
-
# unless current_api_user&.can_edit_others_posts?
-
# media = media.where(status: 'approved')
-
# end
-
-
# Paginate
-
@media = paginate(media.order(created_at: :desc))
-
-
render_success(
-
@media.map { |medium| medium_serializer(medium) },
-
{ filters: filter_meta }
-
)
-
end
-
-
# GET /api/v1/media/:id
-
def show
-
# Set current channel if channel parameter is provided
-
if params[:channel].present?
-
@current_channel = Channel.find_by(slug: params[:channel])
-
end
-
-
render_success(medium_serializer(@medium, detailed: true))
-
end
-
-
# POST /api/v1/media
-
def create
-
unless current_api_user&.can_edit_others_posts?
-
return render_error('You do not have permission to create media', :forbidden)
-
end
-
-
@medium = current_api_user.media.build(medium_params)
-
-
if @medium.save
-
render_success(medium_serializer(@medium), {}, :created)
-
else
-
render_error(@medium.errors.full_messages.join(', '))
-
end
-
end
-
-
# PATCH/PUT /api/v1/media/:id
-
def update
-
unless current_api_user&.can_edit_others_posts?
-
return render_error('You do not have permission to edit media', :forbidden)
-
end
-
-
if @medium.update(medium_params)
-
render_success(medium_serializer(@medium))
-
else
-
render_error(@medium.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/media/:id
-
def destroy
-
unless current_api_user&.can_edit_others_posts?
-
return render_error('You do not have permission to delete media', :forbidden)
-
end
-
-
@medium.destroy
-
render_success({ message: 'Media deleted successfully' })
-
end
-
-
private
-
-
def set_medium
-
@medium = Medium.find(params[:id])
-
end
-
-
def medium_params
-
params.require(:medium).permit(
-
:title, :description, :file, :alt_text, :status
-
)
-
end
-
-
def medium_serializer(medium, detailed: false)
-
# Get channel slugs for this medium
-
channel_slugs = medium.channels.pluck(:slug)
-
-
# Start with basic medium data
-
medium_data = {
-
id: medium.id,
-
title: medium.title,
-
file_name: medium.file_name,
-
file_type: medium.file_type,
-
channels: channel_slugs,
-
channel_context: @current_channel&.slug
-
}
-
-
# Add detailed fields if requested
-
if detailed
-
medium_data.merge!({
-
description: medium.description,
-
alt_text: medium.alt_text,
-
file_size: medium.file_size,
-
created_at: medium.created_at,
-
updated_at: medium.updated_at,
-
url: medium.file_url if medium.respond_to?(:file_url)
-
})
-
end
-
-
# Apply channel overrides if current channel is set
-
if @current_channel
-
original_data = medium_data.dup
-
overridden_data, provenance = @current_channel.apply_overrides_to_data(
-
original_data,
-
'Medium',
-
medium.id,
-
true
-
)
-
-
# Merge overridden data
-
medium_data.merge!(overridden_data)
-
-
# Add provenance information
-
medium_data[:provenance] = provenance if provenance.present?
-
end
-
-
medium_data
-
end
-
-
def filter_meta
-
{
-
type: params[:type],
-
channel: params[:channel]
-
}
-
end
-
end
-
end
-
end
-
module Api
-
module V1
-
class MediaController < BaseController
-
before_action :set_medium, only: [:show, :update, :destroy]
-
-
# GET /api/v1/media
-
def index
-
media = Medium.all
-
-
# Filter by type
-
media = media.by_type(params[:type]) if params[:type].present?
-
-
# Filter by channel
-
if params[:channel].present?
-
channel = Channel.find_by(slug: params[:channel])
-
if channel
-
# Get media assigned to this channel or global media (no channel assignment)
-
# media = media.left_joins(:channels)
-
# .where('channels.id = ? OR channels.id IS NULL', channel.id)
-
-
# Apply channel exclusions
-
# excluded_media_ids = channel.channel_overrides
-
# .exclusions
-
# .enabled
-
# .where(resource_type: 'Medium')
-
# .pluck(:resource_id)
-
# media = media.where.not(id: excluded_media_ids) if excluded_media_ids.any?
-
-
@current_channel = channel
-
end
-
end
-
-
# Only published for non-authenticated or non-admin users
-
unless current_api_user&.can_edit_others_posts?
-
media = media.where(status: 'approved')
-
end
-
-
# Paginate
-
@media = paginate(media.order(created_at: :desc))
-
-
render_success(
-
@media.map { |medium| medium_serializer(medium) },
-
{ filters: filter_meta }
-
)
-
end
-
-
# GET /api/v1/media/:id
-
def show
-
render_success(medium_serializer(@medium, detailed: true))
-
end
-
-
# POST /api/v1/media
-
def create
-
unless current_api_user&.can_edit_others_posts?
-
return render_error('You do not have permission to create media', :forbidden)
-
end
-
-
@medium = current_api_user.media.build(medium_params)
-
-
if @medium.save
-
render_success(medium_serializer(@medium), {}, :created)
-
else
-
render_error(@medium.errors.full_messages.join(', '))
-
end
-
end
-
-
# PATCH/PUT /api/v1/media/:id
-
def update
-
unless current_api_user&.can_edit_others_posts?
-
return render_error('You do not have permission to edit media', :forbidden)
-
end
-
-
if @medium.update(medium_params)
-
render_success(medium_serializer(@medium))
-
else
-
render_error(@medium.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/media/:id
-
def destroy
-
unless current_api_user&.can_edit_others_posts?
-
return render_error('You do not have permission to delete media', :forbidden)
-
end
-
-
@medium.destroy
-
render_success({ message: 'Media deleted successfully' })
-
end
-
-
private
-
-
def set_medium
-
@medium = Medium.find(params[:id])
-
end
-
-
def medium_params
-
params.require(:medium).permit(
-
:title, :description, :file, :alt_text, :status
-
)
-
end
-
-
def medium_serializer(medium, detailed: false)
-
{
-
id: medium.id,
-
title: medium.title,
-
description: medium.description,
-
status: medium.status,
-
created_at: medium.created_at,
-
updated_at: medium.updated_at
-
}
-
end
-
-
def filter_meta
-
{
-
type: params[:type],
-
channel: params[:channel]
-
}
-
end
-
end
-
end
-
end
-
class Api::V1::MediaController < ApplicationController
-
before_action :authenticate_user!
-
before_action :set_medium, only: %i[show update destroy approve reject]
-
before_action :validate_media_permissions
-
-
# GET /api/v1/media
-
def index
-
@media = Medium.all
-
-
# Filter by type
-
if params[:type].present?
-
@media = @media.by_type(params[:type])
-
end
-
-
# Filter by user
-
if params[:user_id].present?
-
@media = @media.where(user_id: params[:user_id])
-
end
-
-
# Filter by quarantine status
-
if params[:quarantined].present?
-
@media = params[:quarantined] == 'true' ? @media.quarantined : @media.approved
-
end
-
-
# Filter by channel
-
if params[:channel].present?
-
channel = Channel.find_by(slug: params[:channel])
-
if channel
-
# Get media assigned to this channel or global media (no channel assignment)
-
@media = @media.left_joins(:channels)
-
.where('channels.id = ? OR channels.id IS NULL', channel.id)
-
-
# Apply channel exclusions
-
excluded_media_ids = channel.channel_overrides
-
.exclusions
-
.enabled
-
.where(resource_type: 'Medium')
-
.pluck(:resource_id)
-
@media = @media.where.not(id: excluded_media_ids) if excluded_media_ids.any?
-
-
@current_channel = channel
-
end
-
end
-
-
# Search
-
if params[:search].present?
-
@media = @media.where("media.title ILIKE ? OR media.description ILIKE ?",
-
"%#{params[:search]}%", "%#{params[:search]}%")
-
end
-
-
# Pagination
-
@media = @media.page(params[:page]).per(params[:per_page] || 20)
-
-
render json: {
-
media: @media.map { |medium| medium_serializer(medium) },
-
pagination: {
-
current_page: @media.current_page,
-
total_pages: @media.total_pages,
-
total_count: @media.total_count,
-
per_page: @media.limit_value
-
},
-
stats: {
-
total: Medium.count,
-
images: Medium.images.count,
-
videos: Medium.videos.count,
-
documents: Medium.documents.count,
-
quarantined: Medium.quarantined.count
-
},
-
filters: {
-
type: params[:type],
-
user_id: params[:user_id],
-
quarantined: params[:quarantined],
-
search: params[:search],
-
channel: params[:channel]
-
}
-
}
-
end
-
-
# GET /api/v1/media/:id
-
def show
-
render json: @medium.api_attributes
-
end
-
-
# POST /api/v1/media
-
def create
-
# Check if we're creating from an existing upload
-
if params[:upload_id].present?
-
upload = current_user.uploads.find(params[:upload_id])
-
-
@medium = Medium.new(medium_params.except(:file))
-
@medium.user = current_user
-
@medium.upload = upload
-
-
if @medium.save
-
render json: @medium.api_attributes, status: :created
-
else
-
render json: {
-
error: 'Media creation failed',
-
details: @medium.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
else
-
# Create new upload and media together
-
if params[:medium][:file].present?
-
# Create upload first
-
upload = current_user.uploads.build(
-
title: params[:medium][:title] || params[:medium][:file].original_filename,
-
description: params[:medium][:description],
-
alt_text: params[:medium][:alt_text]
-
)
-
upload.file.attach(params[:medium][:file])
-
upload.storage_provider = StorageProvider.active.first
-
-
# Security validation
-
security = UploadSecurity.current
-
unless security.file_allowed?(params[:medium][:file])
-
render json: {
-
error: 'File not allowed',
-
details: 'File type, size, or extension is not permitted'
-
}, status: :forbidden
-
return
-
end
-
-
# Check for suspicious files
-
if security.file_suspicious?(params[:medium][:file])
-
if security.quarantine_suspicious?
-
upload.quarantined = true
-
upload.quarantine_reason = 'Suspicious file pattern detected'
-
else
-
render json: {
-
error: 'File rejected',
-
details: 'File appears to be suspicious and has been blocked'
-
}, status: :forbidden
-
return
-
end
-
end
-
-
if upload.save
-
# Create media record
-
@medium = Medium.new(medium_params.except(:file))
-
@medium.user = current_user
-
@medium.upload = upload
-
-
if @medium.save
-
render json: @medium.api_attributes, status: :created
-
else
-
upload.destroy # Clean up upload if media creation fails
-
render json: {
-
error: 'Media creation failed',
-
details: @medium.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
else
-
render json: {
-
error: 'Upload failed',
-
details: upload.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
else
-
render json: {
-
error: 'No file provided',
-
details: 'Either file or upload_id must be provided'
-
}, status: :bad_request
-
end
-
end
-
end
-
-
# PATCH/PUT /api/v1/media/:id
-
def update
-
if @medium.update(medium_params.except(:file))
-
render json: @medium.api_attributes
-
else
-
render json: {
-
error: 'Update failed',
-
details: @medium.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/media/:id
-
def destroy
-
@medium.destroy!
-
head :no_content
-
end
-
-
# POST /api/v1/media/:id/approve
-
def approve
-
if @medium.quarantined?
-
@medium.upload.approve!
-
render json: { message: 'Media approved and released from quarantine' }
-
else
-
render json: { error: 'Media is not quarantined' }, status: :bad_request
-
end
-
end
-
-
# POST /api/v1/media/:id/reject
-
def reject
-
if @medium.quarantined?
-
@medium.destroy!
-
render json: { message: 'Media rejected and deleted' }
-
else
-
render json: { error: 'Media is not quarantined' }, status: :bad_request
-
end
-
end
-
-
private
-
-
def medium_serializer(medium)
-
data = medium.api_attributes.merge({
-
channels: medium.channels.map { |c| c.slug },
-
channel_context: @current_channel&.slug
-
})
-
-
# Apply channel overrides if current channel is set
-
if @current_channel
-
data = @current_channel.apply_overrides_to_data(data, 'Medium', medium.id)
-
-
# Add provenance information
-
data[:provenance] = {
-
title: data[:title] != medium.title ? 'channel_override' : 'resource',
-
description: data[:description] != medium.description ? 'channel_override' : 'resource'
-
}
-
end
-
-
data
-
end
-
-
def set_medium
-
@medium = current_user.media.find(params[:id])
-
end
-
-
def validate_media_permissions
-
unless current_user.can_upload_media?
-
render json: { error: 'Insufficient permissions' }, status: :forbidden
-
end
-
end
-
-
def medium_params
-
params.require(:medium).permit(:title, :description, :alt_text, :file, :upload_id)
-
end
-
end
-
module Api
-
module V1
-
class MenusController < BaseController
-
skip_before_action :authenticate_api_user!, only: [:index, :show]
-
before_action :set_menu, only: [:show, :update, :destroy]
-
-
# GET /api/v1/menus
-
def index
-
menus = Menu.includes(:menu_items)
-
-
# Filter by location
-
menus = menus.by_location(params[:location]) if params[:location].present?
-
-
@menus = paginate(menus)
-
-
render_success(
-
@menus.map { |menu| menu_serializer(menu) }
-
)
-
end
-
-
# GET /api/v1/menus/:id
-
def show
-
render_success(menu_serializer(@menu, detailed: true))
-
end
-
-
# POST /api/v1/menus
-
def create
-
unless current_api_user.can_edit_others_posts?
-
return render_error('You do not have permission to create menus', :forbidden)
-
end
-
-
@menu = Menu.new(menu_params)
-
-
if @menu.save
-
render_success(menu_serializer(@menu), {}, :created)
-
else
-
render_error(@menu.errors.full_messages.join(', '))
-
end
-
end
-
-
# PATCH/PUT /api/v1/menus/:id
-
def update
-
unless current_api_user.can_edit_others_posts?
-
return render_error('You do not have permission to edit menus', :forbidden)
-
end
-
-
if @menu.update(menu_params)
-
render_success(menu_serializer(@menu))
-
else
-
render_error(@menu.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/menus/:id
-
def destroy
-
unless current_api_user.administrator?
-
return render_error('Only administrators can delete menus', :forbidden)
-
end
-
-
@menu.destroy
-
render_success({ message: 'Menu deleted successfully' })
-
end
-
-
private
-
-
def set_menu
-
@menu = Menu.find(params[:id])
-
end
-
-
def menu_params
-
params.require(:menu).permit(:name, :location)
-
end
-
-
def menu_serializer(menu, detailed: false)
-
data = {
-
id: menu.id,
-
name: menu.name,
-
location: menu.location,
-
items_count: menu.menu_items.count
-
}
-
-
if detailed
-
data[:items] = serialize_menu_items(menu.root_items)
-
end
-
-
data
-
end
-
-
def serialize_menu_items(items)
-
items.map do |item|
-
{
-
id: item.id,
-
label: item.label,
-
url: item.url,
-
target: item.target,
-
css_class: item.css_class,
-
position: item.position,
-
children: serialize_menu_items(item.children.ordered)
-
}
-
end
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
class Api::V1::MetaFieldsController < Api::V1::BaseController
-
before_action :authenticate_api_key
-
before_action :set_metable
-
before_action :set_meta_field, only: [:show, :update, :destroy]
-
-
# GET /api/v1/:metable_type/:metable_id/meta_fields
-
def index
-
meta_fields = @metable.meta_fields
-
meta_fields = meta_fields.by_key(params[:key]) if params[:key].present?
-
meta_fields = meta_fields.immutable if params[:immutable] == 'true'
-
meta_fields = meta_fields.mutable if params[:immutable] == 'false'
-
-
render json: {
-
meta_fields: meta_fields.map do |mf|
-
{
-
id: mf.id,
-
key: mf.key,
-
value: mf.value,
-
immutable: mf.immutable,
-
created_at: mf.created_at,
-
updated_at: mf.updated_at
-
}
-
end
-
}
-
end
-
-
# GET /api/v1/:metable_type/:metable_id/meta_fields/:key
-
def show
-
render json: {
-
meta_field: {
-
id: @meta_field.id,
-
key: @meta_field.key,
-
value: @meta_field.value,
-
immutable: @meta_field.immutable,
-
created_at: @meta_field.created_at,
-
updated_at: @meta_field.updated_at
-
}
-
}
-
end
-
-
# POST /api/v1/:metable_type/:metable_id/meta_fields
-
def create
-
meta_field = @metable.meta_fields.build(meta_field_params)
-
-
if meta_field.save
-
render json: {
-
meta_field: {
-
id: meta_field.id,
-
key: meta_field.key,
-
value: meta_field.value,
-
immutable: meta_field.immutable,
-
created_at: meta_field.created_at,
-
updated_at: meta_field.updated_at
-
}
-
}, status: :created
-
else
-
render json: {
-
errors: meta_field.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /api/v1/:metable_type/:metable_id/meta_fields/:key
-
def update
-
if @meta_field.update(meta_field_params)
-
render json: {
-
meta_field: {
-
id: @meta_field.id,
-
key: @meta_field.key,
-
value: @meta_field.value,
-
immutable: @meta_field.immutable,
-
created_at: @meta_field.created_at,
-
updated_at: @meta_field.updated_at
-
}
-
}
-
else
-
render json: {
-
errors: @meta_field.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/:metable_type/:metable_id/meta_fields/:key
-
def destroy
-
if @meta_field.destroy
-
head :no_content
-
else
-
render json: {
-
errors: @meta_field.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# POST /api/v1/:metable_type/:metable_id/meta_fields/bulk
-
def bulk_create
-
meta_fields_data = params[:meta_fields] || []
-
created_meta_fields = []
-
errors = []
-
-
@metable.transaction do
-
meta_fields_data.each do |meta_field_data|
-
meta_field = @metable.meta_fields.build(meta_field_data.permit(:key, :value, :immutable))
-
-
if meta_field.save
-
created_meta_fields << {
-
id: meta_field.id,
-
key: meta_field.key,
-
value: meta_field.value,
-
immutable: meta_field.immutable,
-
created_at: meta_field.created_at,
-
updated_at: meta_field.updated_at
-
}
-
else
-
errors << {
-
key: meta_field_data[:key],
-
errors: meta_field.errors.full_messages
-
}
-
end
-
end
-
-
if errors.any?
-
raise ActiveRecord::Rollback
-
end
-
end
-
-
if errors.any?
-
render json: { errors: errors }, status: :unprocessable_entity
-
else
-
render json: { meta_fields: created_meta_fields }, status: :created
-
end
-
end
-
-
# PATCH /api/v1/:metable_type/:metable_id/meta_fields/bulk
-
def bulk_update
-
meta_fields_data = params[:meta_fields] || {}
-
updated_meta_fields = []
-
errors = []
-
-
@metable.transaction do
-
meta_fields_data.each do |key, data|
-
meta_field = @metable.meta_fields.find_by(key: key)
-
-
if meta_field
-
if meta_field.update(data.permit(:value, :immutable))
-
updated_meta_fields << {
-
id: meta_field.id,
-
key: meta_field.key,
-
value: meta_field.value,
-
immutable: meta_field.immutable,
-
created_at: meta_field.created_at,
-
updated_at: meta_field.updated_at
-
}
-
else
-
errors << {
-
key: key,
-
errors: meta_field.errors.full_messages
-
}
-
end
-
else
-
errors << {
-
key: key,
-
errors: ["Meta field not found"]
-
}
-
end
-
end
-
-
if errors.any?
-
raise ActiveRecord::Rollback
-
end
-
end
-
-
if errors.any?
-
render json: { errors: errors }, status: :unprocessable_entity
-
else
-
render json: { meta_fields: updated_meta_fields }
-
end
-
end
-
-
private
-
-
def authenticate_api_key
-
api_key = request.headers['Authorization']&.split(' ')&.last
-
@api_user = User.find_by(api_key: api_key)
-
-
unless @api_user
-
render json: {
-
error: {
-
message: "Invalid API key",
-
type: "authentication_error",
-
code: "invalid_api_key"
-
}
-
}, status: :unauthorized
-
return false
-
end
-
end
-
-
def set_metable
-
metable_type = params[:metable_type].classify
-
metable_id = params[:metable_id]
-
-
# Validate metable_type
-
unless %w[Post Page User AiAgent].include?(metable_type)
-
render json: {
-
error: {
-
message: "Invalid metable type. Must be one of: Post, Page, User, AiAgent",
-
type: "invalid_request_error",
-
code: "invalid_metable_type"
-
}
-
}, status: :bad_request
-
return
-
end
-
-
@metable = metable_type.constantize.find(metable_id)
-
rescue ActiveRecord::RecordNotFound
-
render json: {
-
error: {
-
message: "#{metable_type} not found",
-
type: "not_found_error",
-
code: "metable_not_found"
-
}
-
}, status: :not_found
-
end
-
-
def set_meta_field
-
@meta_field = @metable.meta_fields.find_by!(key: params[:key])
-
rescue ActiveRecord::RecordNotFound
-
render json: {
-
error: {
-
message: "Meta field not found",
-
type: "not_found_error",
-
code: "meta_field_not_found"
-
}
-
}, status: :not_found
-
end
-
-
def meta_field_params
-
params.require(:meta_field).permit(:key, :value, :immutable)
-
end
-
end
-
class Api::V1::OpenaiController < ApplicationController
-
skip_before_action :verify_authenticity_token
-
before_action :authenticate_api_key
-
before_action :set_agent, only: [:chat_completions]
-
before_action :validate_request, only: [:chat_completions]
-
-
# POST /v1/chat/completions
-
def chat_completions
-
start_time = Time.current
-
-
begin
-
# Execute the agent
-
result = @agent.execute(user_message, build_context, @api_user)
-
-
# Calculate tokens (rough estimation)
-
prompt_tokens = calculate_tokens(full_prompt)
-
completion_tokens = calculate_tokens(result.to_s)
-
total_tokens = prompt_tokens + completion_tokens
-
-
response_time = Time.current - start_time
-
-
# Create usage log
-
@agent.ai_usages.create!(
-
user: @api_user,
-
prompt: full_prompt,
-
response: result.to_s,
-
tokens_used: total_tokens,
-
cost: calculate_cost(prompt_tokens, completion_tokens),
-
response_time: response_time,
-
success: true,
-
metadata: {
-
api_request: true,
-
model: params[:model],
-
messages: params[:messages],
-
temperature: params[:temperature],
-
max_tokens: params[:max_tokens]
-
}
-
)
-
-
render json: {
-
id: generate_chat_id,
-
object: "chat.completion",
-
created: Time.current.to_i,
-
model: params[:model],
-
choices: [
-
{
-
index: 0,
-
message: {
-
role: "assistant",
-
content: result.to_s
-
},
-
finish_reason: "stop"
-
}
-
],
-
usage: {
-
prompt_tokens: prompt_tokens,
-
completion_tokens: completion_tokens,
-
total_tokens: total_tokens
-
}
-
}
-
-
rescue => e
-
response_time = Time.current - start_time
-
-
# Log failed usage
-
@agent.ai_usages.create!(
-
user: @api_user,
-
prompt: full_prompt,
-
response: nil,
-
tokens_used: calculate_tokens(full_prompt),
-
cost: 0.0,
-
response_time: response_time,
-
success: false,
-
error_message: e.message,
-
metadata: {
-
api_request: true,
-
model: params[:model],
-
messages: params[:messages],
-
error_class: e.class.name
-
}
-
)
-
-
render json: {
-
error: {
-
message: e.message,
-
type: "server_error",
-
code: "internal_error"
-
}
-
}, status: :internal_server_error
-
end
-
end
-
-
# GET /v1/models
-
def models
-
models_data = AiAgent.active.includes(:ai_provider).map do |agent|
-
{
-
id: agent.name.parameterize,
-
object: "model",
-
created: agent.created_at.to_i,
-
owned_by: "railspress",
-
permission: [],
-
root: agent.name.parameterize,
-
parent: nil
-
}
-
end
-
-
render json: {
-
object: "list",
-
data: models_data
-
}
-
end
-
-
# GET /v1/models/{id}
-
def model
-
agent = AiAgent.active.find_by("LOWER(REPLACE(name, ' ', '-')) = ?", params[:id].downcase)
-
-
if agent
-
render json: {
-
id: params[:id],
-
object: "model",
-
created: agent.created_at.to_i,
-
owned_by: "railspress",
-
permission: [],
-
root: params[:id],
-
parent: nil
-
}
-
else
-
render json: {
-
error: {
-
message: "The model '#{params[:id]}' does not exist",
-
type: "invalid_request_error",
-
code: "model_not_found"
-
}
-
}, status: :not_found
-
end
-
end
-
-
private
-
-
def authenticate_api_key
-
auth_header = request.headers['Authorization']
-
-
unless auth_header&.start_with?('Bearer ')
-
render json: {
-
error: {
-
message: "Invalid API key provided",
-
type: "invalid_request_error",
-
code: "invalid_api_key"
-
}
-
}, status: :unauthorized
-
return
-
end
-
-
api_key = auth_header.sub('Bearer ', '')
-
-
# Find user by API key (assuming we have an api_key field on User model)
-
@api_user = User.find_by(api_key: api_key)
-
-
unless @api_user
-
render json: {
-
error: {
-
message: "Invalid API key provided",
-
type: "invalid_request_error",
-
code: "invalid_api_key"
-
}
-
}, status: :unauthorized
-
end
-
end
-
-
def set_agent
-
model_name = params[:model]
-
-
# Find agent by model name (parameterized)
-
@agent = AiAgent.active.find_by("LOWER(REPLACE(name, ' ', '-')) = ?", model_name.downcase)
-
-
unless @agent
-
render json: {
-
error: {
-
message: "The model '#{model_name}' does not exist or is not available",
-
type: "invalid_request_error",
-
code: "model_not_found"
-
}
-
}, status: :not_found
-
end
-
end
-
-
def validate_request
-
unless params[:messages].is_a?(Array) && params[:messages].any?
-
render json: {
-
error: {
-
message: "messages is required",
-
type: "invalid_request_error",
-
code: "missing_messages"
-
}
-
}, status: :bad_request
-
return
-
end
-
-
# Validate message format
-
params[:messages].each do |message|
-
unless message['role'] && message['content']
-
render json: {
-
error: {
-
message: "Each message must have 'role' and 'content'",
-
type: "invalid_request_error",
-
code: "invalid_message_format"
-
}
-
}, status: :bad_request
-
return
-
end
-
end
-
end
-
-
def user_message
-
# Get the last user message
-
user_messages = params[:messages].select { |m| m['role'] == 'user' }
-
user_messages.last&.dig('content') || ""
-
end
-
-
def system_message
-
# Get the system message if present
-
system_messages = params[:messages].select { |m| m['role'] == 'system' }
-
system_messages.first&.dig('content') || ""
-
end
-
-
def full_prompt
-
# Combine system message with agent prompt
-
parts = []
-
parts << system_message if system_message.present?
-
parts << @agent.prompt if @agent.prompt.present?
-
parts << "User Input: #{user_message}"
-
parts.join("\n\n")
-
end
-
-
def build_context
-
{
-
temperature: params[:temperature] || @agent.ai_provider.temperature,
-
max_tokens: params[:max_tokens] || @agent.ai_provider.max_tokens,
-
model: params[:model],
-
api_request: true
-
}
-
end
-
-
def calculate_tokens(text)
-
# Simple token estimation: ~4 characters per token
-
(text.to_s.length / 4.0).ceil
-
end
-
-
def calculate_cost(prompt_tokens, completion_tokens)
-
# Simple cost calculation based on provider
-
total_tokens = prompt_tokens + completion_tokens
-
case @agent.ai_provider.provider_type
-
when 'openai'
-
total_tokens * 0.00002
-
when 'anthropic'
-
total_tokens * 0.000015
-
else
-
total_tokens * 0.00001
-
end
-
end
-
-
def generate_chat_id
-
"chatcmpl_#{SecureRandom.hex(12)}"
-
end
-
end
-
module Api
-
module V1
-
class PagesController < BaseController
-
before_action :set_page, only: [:show, :update, :destroy]
-
-
# GET /api/v1/pages
-
def index
-
pages = Page.all
-
-
# Filter by status
-
pages = pages.where(status: params[:status]) if params[:status].present?
-
-
# Filter by parent
-
pages = pages.where(parent_id: params[:parent_id]) if params[:parent_id].present?
-
-
# Root pages only
-
pages = pages.root_pages if params[:root_only] == 'true'
-
-
# Filter by channel
-
if params[:channel].present?
-
channel = Channel.find_by(slug: params[:channel])
-
if channel
-
# Get pages assigned to this channel or global pages (no channel assignment)
-
pages = pages.left_joins(:channels)
-
.where('channels.id = ? OR channels.id IS NULL', channel.id)
-
-
# Apply channel exclusions
-
excluded_page_ids = channel.channel_overrides
-
.exclusions
-
.enabled
-
.where(resource_type: 'Page')
-
.pluck(:resource_id)
-
pages = pages.where.not(id: excluded_page_ids) if excluded_page_ids.any?
-
-
@current_channel = channel
-
end
-
end
-
-
# Only published for non-authenticated or non-admin users
-
unless current_api_user&.can_edit_others_posts?
-
pages = pages.published
-
end
-
-
# Paginate
-
@pages = paginate(pages.order(order: :asc, created_at: :desc))
-
-
render_success(
-
@pages.map { |page| page_serializer(page) },
-
{ filters: filter_meta }
-
)
-
end
-
-
# GET /api/v1/pages/:id
-
def show
-
# Set current channel if channel parameter is provided
-
if params[:channel].present?
-
@current_channel = Channel.find_by(slug: params[:channel])
-
end
-
-
render_success(page_serializer(@page, detailed: true))
-
end
-
-
# POST /api/v1/pages
-
def create
-
unless current_api_user.can_publish?
-
return render_error('You do not have permission to create pages', :forbidden)
-
end
-
-
@page = current_api_user.pages.build(page_params)
-
-
if @page.save
-
render_success(page_serializer(@page), {}, :created)
-
else
-
render_error(@page.errors.full_messages.join(', '))
-
end
-
end
-
-
# PATCH/PUT /api/v1/pages/:id
-
def update
-
unless can_edit_page?
-
return render_error('You do not have permission to edit this page', :forbidden)
-
end
-
-
if @page.update(page_params)
-
render_success(page_serializer(@page))
-
else
-
render_error(@page.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/pages/:id
-
def destroy
-
unless current_api_user.can_delete_posts?
-
return render_error('You do not have permission to delete pages', :forbidden)
-
end
-
-
@page.destroy
-
render_success({ message: 'Page deleted successfully' })
-
end
-
-
private
-
-
def set_page
-
@page = Page.friendly.find(params[:id])
-
end
-
-
def can_edit_page?
-
return true if current_api_user.can_edit_others_posts?
-
@page.user_id == current_api_user.id
-
end
-
-
def page_params
-
params.require(:page).permit(
-
:title, :slug, :content, :status, :published_at,
-
:parent_id, :order, :template, :meta_description, :meta_keywords
-
)
-
end
-
-
def page_serializer(page, detailed: false)
-
# Get channel slugs for this page
-
channel_slugs = page.channels.pluck(:slug)
-
-
# Start with basic page data
-
page_data = {
-
id: page.id,
-
title: page.title,
-
slug: page.slug,
-
status: page.status,
-
channels: channel_slugs,
-
channel_context: @current_channel&.slug
-
}
-
-
# Add detailed fields if requested
-
if detailed
-
page_data.merge!({
-
content: page.content,
-
published_at: page.published_at,
-
parent_id: page.parent_id,
-
order: page.order,
-
template: page.template,
-
created_at: page.created_at,
-
updated_at: page.updated_at,
-
url: Rails.application.routes.url_helpers.page_url(page, host: request.host)
-
})
-
end
-
-
# Apply channel overrides if current channel is set
-
if @current_channel
-
original_data = page_data.dup
-
overridden_data, provenance = @current_channel.apply_overrides_to_data(
-
original_data,
-
'Page',
-
page.id,
-
true
-
)
-
-
# Merge overridden data
-
page_data.merge!(overridden_data)
-
-
# Add provenance information
-
page_data[:provenance] = provenance if provenance.present?
-
end
-
-
page_data
-
end
-
-
def filter_meta
-
{
-
status: params[:status],
-
parent_id: params[:parent_id],
-
root_only: params[:root_only],
-
channel: params[:channel]
-
}
-
end
-
end
-
end
-
end
-
-
-
-
-
-
module Api
-
module V1
-
class PostsController < BaseController
-
before_action :set_post, only: [:show, :update, :destroy]
-
-
# GET /api/v1/posts
-
def index
-
posts = Post.all
-
-
# Filter by status
-
posts = posts.where(status: params[:status]) if params[:status].present?
-
-
# Filter by category
-
posts = posts.by_category(params[:category]) if params[:category].present?
-
-
# Filter by tag
-
posts = posts.by_tag(params[:tag]) if params[:tag].present?
-
-
# Filter by channel
-
if params[:channel].present?
-
channel = Channel.find_by(slug: params[:channel])
-
if channel
-
# Get posts assigned to this channel or global posts (no channel assignment)
-
posts = posts.left_joins(:channels)
-
.where('channels.id = ? OR channels.id IS NULL', channel.id)
-
-
# Apply channel exclusions
-
excluded_post_ids = channel.channel_overrides
-
.exclusions
-
.enabled
-
.where(resource_type: 'Post')
-
.pluck(:resource_id)
-
posts = posts.where.not(id: excluded_post_ids) if excluded_post_ids.any?
-
-
@current_channel = channel
-
end
-
elsif params[:auto_channel].present?
-
# Use auto-detected channel from middleware
-
channel = Channel.find_by(slug: params[:auto_channel])
-
if channel
-
posts = posts.left_joins(:channels)
-
.where('channels.id = ? OR channels.id IS NULL', channel.id)
-
-
excluded_post_ids = channel.channel_overrides
-
.exclusions
-
.enabled
-
.where(resource_type: 'Post')
-
.pluck(:resource_id)
-
posts = posts.where.not(id: excluded_post_ids) if excluded_post_ids.any?
-
-
@current_channel = channel
-
end
-
end
-
-
# Search
-
posts = posts.search(params[:q]) if params[:q].present?
-
-
# Only published for non-authenticated or non-admin users
-
unless current_api_user&.can_edit_others_posts?
-
posts = posts.published
-
end
-
-
# Paginate
-
@posts = paginate(posts.order(created_at: :desc))
-
-
render_success(
-
@posts.map { |post| post_serializer(post) },
-
{ filters: filter_meta }
-
)
-
end
-
-
# GET /api/v1/posts/:id
-
def show
-
# Set current channel if channel parameter is provided
-
if params[:channel].present?
-
@current_channel = Channel.find_by(slug: params[:channel])
-
elsif params[:auto_channel].present?
-
# Use auto-detected channel from middleware
-
@current_channel = Channel.find_by(slug: params[:auto_channel])
-
end
-
-
render_success(post_serializer(@post, detailed: true))
-
end
-
-
# POST /api/v1/posts
-
def create
-
unless current_api_user.can_publish?
-
return render_error('You do not have permission to create posts', :forbidden)
-
end
-
-
@post = current_api_user.posts.build(post_params)
-
-
if @post.save
-
render_success(post_serializer(@post), {}, :created)
-
else
-
render_error(@post.errors.full_messages.join(', '))
-
end
-
end
-
-
# PATCH/PUT /api/v1/posts/:id
-
def update
-
unless can_edit_post?
-
return render_error('You do not have permission to edit this post', :forbidden)
-
end
-
-
if @post.update(post_params)
-
render_success(post_serializer(@post))
-
else
-
render_error(@post.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/posts/:id
-
def destroy
-
unless current_api_user.can_delete_posts?
-
return render_error('You do not have permission to delete posts', :forbidden)
-
end
-
-
@post.destroy
-
render_success({ message: 'Post deleted successfully' })
-
end
-
-
private
-
-
def set_post
-
@post = Post.friendly.find(params[:id])
-
end
-
-
def can_edit_post?
-
return true if current_api_user.can_edit_others_posts?
-
@post.user_id == current_api_user.id
-
end
-
-
def post_params
-
params.require(:post).permit(
-
:title, :slug, :content, :excerpt, :status, :published_at,
-
:featured_image, :meta_description, :meta_keywords,
-
category_ids: [], tag_ids: []
-
)
-
end
-
-
def post_serializer(post, detailed: false)
-
# Get channel slugs for this post
-
channel_slugs = post.channels.pluck(:slug)
-
-
# Start with basic post data
-
post_data = {
-
id: post.id,
-
title: post.title,
-
slug: post.slug,
-
status: post.status,
-
channels: channel_slugs,
-
channel_context: @current_channel&.slug
-
}
-
-
# Add detailed fields if requested
-
if detailed
-
post_data.merge!({
-
content: post.content,
-
excerpt: post.excerpt,
-
published_at: post.published_at,
-
created_at: post.created_at,
-
updated_at: post.updated_at,
-
url: Rails.application.routes.url_helpers.blog_post_url(post, host: request.host)
-
})
-
end
-
-
# Apply channel overrides if current channel is set
-
if @current_channel
-
original_data = post_data.dup
-
overridden_data, provenance = @current_channel.apply_overrides_to_data(
-
original_data,
-
'Post',
-
post.id,
-
true
-
)
-
-
# Merge overridden data
-
post_data.merge!(overridden_data)
-
-
# Add provenance information
-
post_data[:provenance] = provenance if provenance.present?
-
end
-
-
post_data
-
end
-
-
def filter_meta
-
{
-
status: params[:status],
-
category: params[:category],
-
tag: params[:tag],
-
search: params[:q],
-
channel: params[:channel] || params[:auto_channel]
-
}
-
end
-
end
-
end
-
end
-
-
-
-
module Api
-
module V1
-
class SettingsController < BaseController
-
before_action :ensure_admin, except: [:index, :show]
-
-
# GET /api/v1/settings
-
def index
-
settings = SiteSetting.all
-
-
render_success(
-
settings.map { |s| setting_serializer(s) }
-
)
-
end
-
-
# GET /api/v1/settings/:key
-
def show
-
setting = SiteSetting.find_by!(key: params[:id])
-
render_success(setting_serializer(setting))
-
end
-
-
# POST /api/v1/settings
-
def create
-
key = params[:setting][:key]
-
value = params[:setting][:value]
-
setting_type = params[:setting][:setting_type] || 'string'
-
-
if SiteSetting.set(key, value, setting_type)
-
setting = SiteSetting.find_by(key: key)
-
render_success(setting_serializer(setting), {}, :created)
-
else
-
render_error('Failed to create setting')
-
end
-
end
-
-
# PATCH/PUT /api/v1/settings/:key
-
def update
-
setting = SiteSetting.find_by!(key: params[:id])
-
-
if setting.update(setting_params)
-
render_success(setting_serializer(setting))
-
else
-
render_error(setting.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/settings/:key
-
def destroy
-
setting = SiteSetting.find_by!(key: params[:id])
-
setting.destroy
-
render_success({ message: 'Setting deleted successfully' })
-
end
-
-
# GET /api/v1/settings/get/:key
-
def get_value
-
value = SiteSetting.get(params[:key], params[:default])
-
render_success({ key: params[:key], value: value })
-
end
-
-
private
-
-
def ensure_admin
-
unless current_api_user.administrator?
-
render_error('Only administrators can manage settings', :forbidden)
-
end
-
end
-
-
def setting_params
-
params.require(:setting).permit(:key, :value, :setting_type)
-
end
-
-
def setting_serializer(setting)
-
{
-
key: setting.key,
-
value: setting.typed_value,
-
raw_value: setting.value,
-
setting_type: setting.setting_type
-
}
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
module Api
-
module V1
-
class SimpleController < BaseController
-
def index
-
render json: { message: "Hello World", success: true }
-
end
-
end
-
end
-
end
-
module Api
-
module V1
-
class SubscribersController < BaseController
-
skip_before_action :authenticate_api_user!, only: [:create, :unsubscribe, :confirm]
-
before_action :set_subscriber, only: [:show, :update, :destroy]
-
-
# GET /api/v1/subscribers
-
def index
-
unless current_api_user.can_edit_others_posts?
-
return render_error('You do not have permission to view subscribers', :forbidden)
-
end
-
-
subscribers = Subscriber.all
-
-
# Filter by status
-
subscribers = subscribers.where(status: params[:status]) if params[:status].present?
-
-
# Filter by source
-
subscribers = subscribers.by_source(params[:source]) if params[:source].present?
-
-
# Search
-
subscribers = subscribers.search(params[:q]) if params[:q].present?
-
-
# Paginate
-
@subscribers = paginate(subscribers.recent)
-
-
render_success(
-
@subscribers.map { |s| subscriber_serializer(s) }
-
)
-
end
-
-
# GET /api/v1/subscribers/:id
-
def show
-
unless current_api_user.can_edit_others_posts?
-
return render_error('You do not have permission to view subscribers', :forbidden)
-
end
-
-
render_success(subscriber_serializer(@subscriber, detailed: true))
-
end
-
-
# POST /api/v1/subscribers
-
# Public endpoint for newsletter signups
-
def create
-
@subscriber = Subscriber.new(subscriber_create_params)
-
@subscriber.status = 'pending'
-
@subscriber.source = params[:source] || 'api'
-
@subscriber.ip_address = request.remote_ip
-
@subscriber.user_agent = request.user_agent
-
-
if @subscriber.save
-
render_success(
-
{
-
message: 'Successfully subscribed! Please check your email to confirm.',
-
subscriber: subscriber_serializer(@subscriber)
-
},
-
{},
-
:created
-
)
-
else
-
render_error(@subscriber.errors.full_messages.join(', '))
-
end
-
end
-
-
# PATCH/PUT /api/v1/subscribers/:id
-
def update
-
unless current_api_user.administrator?
-
return render_error('Only administrators can update subscribers', :forbidden)
-
end
-
-
if @subscriber.update(subscriber_update_params)
-
render_success(subscriber_serializer(@subscriber))
-
else
-
render_error(@subscriber.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/subscribers/:id
-
def destroy
-
unless current_api_user.administrator?
-
return render_error('Only administrators can delete subscribers', :forbidden)
-
end
-
-
@subscriber.destroy
-
render_success({ message: 'Subscriber deleted successfully' })
-
end
-
-
# POST /api/v1/subscribers/unsubscribe
-
# Public endpoint for unsubscribing
-
def unsubscribe
-
subscriber = Subscriber.find_by(unsubscribe_token: params[:token])
-
-
unless subscriber
-
return render_error('Invalid unsubscribe token', :not_found)
-
end
-
-
subscriber.unsubscribe!
-
-
render_success({
-
message: 'Successfully unsubscribed',
-
email: subscriber.email
-
})
-
end
-
-
# POST /api/v1/subscribers/confirm
-
# Public endpoint for confirming subscription
-
def confirm
-
subscriber = Subscriber.find_by(unsubscribe_token: params[:token])
-
-
unless subscriber
-
return render_error('Invalid confirmation token', :not_found)
-
end
-
-
if subscriber.confirmed_status?
-
return render_success({
-
message: 'Already confirmed',
-
email: subscriber.email
-
})
-
end
-
-
subscriber.confirm!
-
-
render_success({
-
message: 'Email confirmed! You are now subscribed.',
-
email: subscriber.email
-
})
-
end
-
-
# GET /api/v1/subscribers/stats
-
def stats
-
unless current_api_user.can_edit_others_posts?
-
return render_error('You do not have permission to view stats', :forbidden)
-
end
-
-
render_success(Subscriber.stats)
-
end
-
-
private
-
-
def set_subscriber
-
@subscriber = Subscriber.find(params[:id])
-
end
-
-
def subscriber_create_params
-
params.require(:subscriber).permit(:email, :name)
-
end
-
-
def subscriber_update_params
-
params.require(:subscriber).permit(:email, :name, :status, :source, tags: [], lists: [])
-
end
-
-
def subscriber_serializer(subscriber, detailed: false)
-
data = {
-
id: subscriber.id,
-
email: subscriber.email,
-
name: subscriber.name,
-
status: subscriber.status,
-
source: subscriber.source,
-
tags: subscriber.tags || [],
-
lists: subscriber.lists || [],
-
created_at: subscriber.created_at.iso8601
-
}
-
-
if detailed
-
data.merge!(
-
confirmed_at: subscriber.confirmed_at&.iso8601,
-
unsubscribed_at: subscriber.unsubscribed_at&.iso8601,
-
ip_address: subscriber.ip_address,
-
user_agent: subscriber.user_agent,
-
metadata: subscriber.metadata
-
)
-
end
-
-
data
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
module Api
-
module V1
-
class SystemController < BaseController
-
skip_before_action :authenticate_api_user!, only: [:info]
-
-
# GET /api/v1/system/info
-
def info
-
render_success({
-
name: 'RailsPress API',
-
version: 'v1',
-
rails_version: Rails.version,
-
ruby_version: RUBY_VERSION,
-
environment: Rails.env,
-
endpoints: {
-
posts: api_v1_posts_url,
-
pages: api_v1_pages_url,
-
categories: api_v1_categories_url,
-
tags: api_v1_tags_url,
-
comments: api_v1_comments_url,
-
media: api_v1_media_index_url,
-
users: api_v1_users_url,
-
menus: api_v1_menus_url,
-
settings: api_v1_settings_url
-
},
-
documentation: 'https://github.com/railspress/api-docs'
-
})
-
end
-
-
# GET /api/v1/system/stats
-
def stats
-
unless current_api_user.administrator?
-
return render_error('Only administrators can view system stats', :forbidden)
-
end
-
-
render_success({
-
content: {
-
total_posts: Post.count,
-
published_posts: Post.published.count,
-
draft_posts: Post.draft_status.count,
-
total_pages: Page.count,
-
published_pages: Page.published.count,
-
total_comments: Comment.count,
-
approved_comments: Comment.approved.count,
-
pending_comments: Comment.pending.count,
-
spam_comments: Comment.spam.count
-
},
-
taxonomy: {
-
categories: Term.for_taxonomy('category').count,
-
tags: Term.for_taxonomy('post_tag').count
-
},
-
media: {
-
total_files: Medium.count,
-
images: Medium.images.count,
-
videos: Medium.videos.count,
-
documents: Medium.documents.count,
-
total_size_mb: (Medium.sum(:file_size).to_f / 1024 / 1024).round(2)
-
},
-
users: {
-
total: User.count,
-
administrators: User.administrator.count,
-
editors: User.editor.count,
-
authors: User.author.count,
-
contributors: User.contributor.count,
-
subscribers: User.subscriber.count
-
},
-
system: {
-
themes: Theme.count,
-
active_theme: Theme.active.first&.name,
-
plugins: Plugin.count,
-
active_plugins: Plugin.active.count,
-
menus: Menu.count,
-
widgets: Widget.count,
-
active_widgets: Widget.active.count
-
}
-
})
-
end
-
end
-
end
-
end
-
-
-
-
module Api
-
module V1
-
class TagsController < Api::V1::BaseController
-
before_action :set_taxonomy
-
before_action :set_tag, only: [:show, :update, :destroy]
-
-
# GET /api/v1/tags
-
def index
-
tags = @taxonomy.terms.includes(:term_relationships).order(:name)
-
-
render json: tags.map { |tag| tag_json(tag) }
-
end
-
-
# GET /api/v1/tags/:id
-
def show
-
render json: tag_json(@tag)
-
end
-
-
# POST /api/v1/tags
-
def create
-
@tag = @taxonomy.terms.new(tag_params)
-
-
if @tag.save
-
render json: tag_json(@tag), status: :created
-
else
-
render json: { errors: @tag.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
-
# PATCH/PUT /api/v1/tags/:id
-
def update
-
if @tag.update(tag_params)
-
render json: tag_json(@tag)
-
else
-
render json: { errors: @tag.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/tags/:id
-
def destroy
-
@tag.destroy
-
head :no_content
-
end
-
-
private
-
-
def set_taxonomy
-
@taxonomy = Taxonomy.find_by!(slug: 'tag')
-
rescue ActiveRecord::RecordNotFound
-
render json: { error: 'Tag taxonomy not found' }, status: :not_found
-
end
-
-
def set_tag
-
@tag = @taxonomy.terms.friendly.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
render json: { error: 'Tag not found' }, status: :not_found
-
end
-
-
def tag_params
-
params.require(:tag).permit(:name, :slug, :description, :meta)
-
end
-
-
def tag_json(tag)
-
{
-
id: tag.id,
-
name: tag.name,
-
slug: tag.slug,
-
description: tag.description,
-
count: tag.term_relationships.where(object_type: 'Post').count,
-
meta: tag.meta,
-
created_at: tag.created_at,
-
updated_at: tag.updated_at,
-
url: "/blog/tag/#{tag.slug}"
-
}
-
end
-
end
-
end
-
end
-
module Api
-
module V1
-
class TaxonomiesController < BaseController
-
skip_before_action :authenticate_api_user!, only: [:index, :show, :terms]
-
before_action :set_taxonomy, only: [:show, :update, :destroy, :terms]
-
-
# GET /api/v1/taxonomies
-
def index
-
taxonomies = Taxonomy.all
-
-
# Filter by object type
-
taxonomies = taxonomies.where("object_types LIKE ?", "%#{params[:object_type]}%") if params[:object_type].present?
-
-
# Filter by type
-
case params[:type]
-
when 'hierarchical'
-
taxonomies = taxonomies.hierarchical
-
when 'flat'
-
taxonomies = taxonomies.flat
-
end
-
-
@taxonomies = paginate(taxonomies.order(:name))
-
-
render_success(
-
@taxonomies.map { |taxonomy| taxonomy_serializer(taxonomy) }
-
)
-
end
-
-
# GET /api/v1/taxonomies/:id
-
def show
-
render_success(taxonomy_serializer(@taxonomy, detailed: true))
-
end
-
-
# GET /api/v1/taxonomies/:id/terms
-
def terms
-
terms = @taxonomy.terms.includes(:parent, :children)
-
-
# Root terms only
-
terms = terms.root_terms if params[:root_only] == 'true'
-
-
@terms = paginate(terms.ordered)
-
-
render_success(
-
@terms.map { |term| term_serializer(term) }
-
)
-
end
-
-
# POST /api/v1/taxonomies
-
def create
-
unless current_api_user.administrator?
-
return render_error('Only administrators can create taxonomies', :forbidden)
-
end
-
-
@taxonomy = Taxonomy.new(taxonomy_params)
-
-
if @taxonomy.save
-
render_success(taxonomy_serializer(@taxonomy), {}, :created)
-
else
-
render_error(@taxonomy.errors.full_messages.join(', '))
-
end
-
end
-
-
# PATCH/PUT /api/v1/taxonomies/:id
-
def update
-
unless current_api_user.administrator?
-
return render_error('Only administrators can update taxonomies', :forbidden)
-
end
-
-
if @taxonomy.update(taxonomy_params)
-
render_success(taxonomy_serializer(@taxonomy))
-
else
-
render_error(@taxonomy.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/taxonomies/:id
-
def destroy
-
unless current_api_user.administrator?
-
return render_error('Only administrators can delete taxonomies', :forbidden)
-
end
-
-
@taxonomy.destroy
-
render_success({ message: 'Taxonomy deleted successfully' })
-
end
-
-
private
-
-
def set_taxonomy
-
@taxonomy = Taxonomy.friendly.find(params[:id])
-
end
-
-
def taxonomy_params
-
params.require(:taxonomy).permit(:name, :slug, :description, :hierarchical, object_types: [], settings: {})
-
end
-
-
def taxonomy_serializer(taxonomy, detailed: false)
-
data = {
-
id: taxonomy.id,
-
name: taxonomy.name,
-
slug: taxonomy.slug,
-
description: taxonomy.description,
-
hierarchical: taxonomy.hierarchical?,
-
object_types: taxonomy.object_types,
-
term_count: taxonomy.term_count
-
}
-
-
if detailed
-
data.merge!(
-
terms: taxonomy.root_terms.map { |term| term_serializer(term) },
-
settings: taxonomy.settings
-
)
-
end
-
-
data
-
end
-
-
def term_serializer(term)
-
{
-
id: term.id,
-
name: term.name,
-
slug: term.slug,
-
description: term.description,
-
count: term.count,
-
parent_id: term.parent_id,
-
parent: term.parent ? { id: term.parent.id, name: term.parent.name } : nil,
-
children_count: term.children.count
-
}
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
module Api
-
module V1
-
class TermsController < BaseController
-
skip_before_action :authenticate_api_user!, only: [:index, :show]
-
before_action :set_taxonomy, except: [:index, :show]
-
before_action :set_term, only: [:show, :update, :destroy]
-
-
# GET /api/v1/terms
-
def index
-
terms = Term.includes(:taxonomy, :parent)
-
-
# Filter by taxonomy
-
terms = terms.for_taxonomy(params[:taxonomy]) if params[:taxonomy].present?
-
-
# Search
-
terms = terms.where('name LIKE ?', "%#{params[:q]}%") if params[:q].present?
-
-
@terms = paginate(terms.ordered)
-
-
render_success(
-
@terms.map { |term| term_serializer(term) }
-
)
-
end
-
-
# GET /api/v1/terms/:id
-
def show
-
render_success(term_serializer(@term, detailed: true))
-
end
-
-
# POST /api/v1/taxonomies/:taxonomy_id/terms
-
def create
-
unless current_api_user.can_edit_others_posts?
-
return render_error('You do not have permission to create terms', :forbidden)
-
end
-
-
@term = @taxonomy.terms.build(term_params)
-
-
if @term.save
-
render_success(term_serializer(@term), {}, :created)
-
else
-
render_error(@term.errors.full_messages.join(', '))
-
end
-
end
-
-
# PATCH/PUT /api/v1/taxonomies/:taxonomy_id/terms/:id
-
def update
-
unless current_api_user.can_edit_others_posts?
-
return render_error('You do not have permission to update terms', :forbidden)
-
end
-
-
if @term.update(term_params)
-
render_success(term_serializer(@term))
-
else
-
render_error(@term.errors.full_messages.join(', '))
-
end
-
end
-
-
# DELETE /api/v1/taxonomies/:taxonomy_id/terms/:id
-
def destroy
-
unless current_api_user.administrator?
-
return render_error('Only administrators can delete terms', :forbidden)
-
end
-
-
@term.destroy
-
render_success({ message: 'Term deleted successfully' })
-
end
-
-
private
-
-
def set_taxonomy
-
@taxonomy = Taxonomy.friendly.find(params[:taxonomy_id])
-
end
-
-
def set_term
-
if params[:taxonomy_id]
-
@term = @taxonomy.terms.friendly.find(params[:id])
-
else
-
@term = Term.friendly.find(params[:id])
-
end
-
end
-
-
def term_params
-
params.require(:term).permit(:name, :slug, :description, :parent_id, metadata: {})
-
end
-
-
def term_serializer(term, detailed: false)
-
data = {
-
id: term.id,
-
name: term.name,
-
slug: term.slug,
-
description: term.description,
-
count: term.count,
-
taxonomy: {
-
id: term.taxonomy.id,
-
name: term.taxonomy.name,
-
slug: term.taxonomy.slug
-
},
-
parent: term.parent ? { id: term.parent.id, name: term.parent.name, slug: term.parent.slug } : nil,
-
children_count: term.children.count
-
}
-
-
if detailed
-
data.merge!(
-
children: term.children.map { |c| { id: c.id, name: c.name, slug: c.slug } },
-
breadcrumbs: term.breadcrumbs.map { |b| { id: b.id, name: b.name, slug: b.slug } },
-
metadata: term.metadata
-
)
-
end
-
-
data
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
module Api
-
module V1
-
class TestController < BaseController
-
def index
-
begin
-
posts = Post.includes(:user, :categories, :tags, :comments, :channels)
-
posts = posts.where(status: 'published')
-
posts = posts.left_joins(:channels)
-
posts = posts.order(created_at: :desc)
-
-
@posts = posts.limit(10)
-
-
render_success(
-
@posts.map { |post| simple_serializer(post) },
-
{ message: 'Test successful' }
-
)
-
rescue => e
-
render_error("Error: #{e.message}")
-
end
-
end
-
-
private
-
-
def simple_serializer(post)
-
{
-
id: post.id,
-
title: post.title,
-
slug: post.slug,
-
status: post.status,
-
channels: post.channels.map { |c| c.slug }
-
}
-
end
-
end
-
end
-
end
-
class Api::V1::ThemesController < Api::V1::BaseController
-
before_action :authenticate_api_key
-
before_action :set_theme, only: [:show, :screenshot]
-
skip_before_action :set_content_type, only: [:screenshot]
-
-
# GET /api/v1/themes
-
def index
-
@themes = Theme.includes(:published_version)
-
-
render json: {
-
themes: @themes.map do |theme|
-
{
-
id: theme.id,
-
name: theme.name,
-
slug: theme.slug,
-
description: theme.description,
-
version: theme.version,
-
active: theme.active?,
-
screenshot_url: api_v1_theme_screenshot_url(theme.id),
-
created_at: theme.created_at,
-
updated_at: theme.updated_at
-
}
-
end
-
}
-
end
-
-
# GET /api/v1/themes/:id
-
def show
-
render json: {
-
theme: {
-
id: @theme.id,
-
name: @theme.name,
-
slug: @theme.slug,
-
description: @theme.description,
-
version: @theme.version,
-
active: @theme.active?,
-
screenshot_url: api_v1_theme_screenshot_url(@theme.id),
-
created_at: @theme.created_at,
-
updated_at: @theme.updated_at
-
}
-
}
-
end
-
-
# GET /api/v1/themes/:id/screenshot
-
def screenshot
-
cache_key = "theme_screenshot_#{@theme.id}"
-
Rails.logger.info "API: Cache key: #{cache_key}"
-
-
# Try to get from cache first
-
cached_screenshot = Rails.cache.read(cache_key)
-
Rails.logger.info "API: Cache read result: #{cached_screenshot ? 'HIT' : 'MISS'}"
-
-
if cached_screenshot
-
Rails.logger.info "API: Serving cached screenshot for theme #{@theme.id} (size: #{cached_screenshot.bytesize} bytes)"
-
send_data cached_screenshot,
-
type: 'image/png',
-
disposition: 'inline',
-
filename: "theme_#{@theme.id}_screenshot.png"
-
return
-
end
-
-
begin
-
# Generate new screenshot
-
Rails.logger.info "API: Generating new screenshot for theme #{@theme.id}"
-
screenshot_data = ScreenshotService.capture_theme_screenshot_data(@theme, {
-
width: 1200,
-
height: 800,
-
format: :png
-
})
-
-
# Ferrum returns base64-encoded PNG data, decode it to binary
-
if screenshot_data.match?(/^[A-Za-z0-9+\/]*={0,2}$/)
-
screenshot_data = Base64.decode64(screenshot_data)
-
end
-
-
# Cache the screenshot for 1 hour
-
Rails.logger.info "API: Caching screenshot for theme #{@theme.id} (size: #{screenshot_data.bytesize} bytes)"
-
Rails.cache.write(cache_key, screenshot_data, expires_in: 1.hour)
-
-
send_data screenshot_data,
-
type: 'image/png',
-
disposition: 'inline',
-
filename: "theme_#{@theme.id}_screenshot.png"
-
rescue => e
-
Rails.logger.error "API: Screenshot capture failed for theme #{@theme.id}: #{e.message}"
-
render json: {
-
error: {
-
message: "Failed to capture screenshot",
-
type: "screenshot_error",
-
code: "screenshot_failed",
-
details: e.message
-
}
-
}, status: :internal_server_error
-
end
-
end
-
-
private
-
-
def set_theme
-
@theme = Theme.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
render json: {
-
error: {
-
message: "Theme not found",
-
type: "not_found_error",
-
code: "theme_not_found"
-
}
-
}, status: :not_found
-
end
-
end
-
class Api::V1::UploadsController < ApplicationController
-
before_action :authenticate_user!
-
before_action :set_upload_security
-
before_action :validate_upload_permissions
-
-
# POST /api/v1/uploads
-
def create
-
@upload = Upload.new(upload_params)
-
@upload.user = current_user
-
@upload.storage_provider = StorageProvider.active.first
-
-
# Security validation
-
unless @upload_security.file_allowed?(@upload.file)
-
render json: {
-
error: 'File not allowed',
-
details: 'File type, size, or extension is not permitted'
-
}, status: :forbidden
-
return
-
end
-
-
# Check for suspicious files
-
if @upload_security.file_suspicious?(@upload.file)
-
if @upload_security.quarantine_suspicious?
-
@upload.quarantined = true
-
@upload.quarantine_reason = 'Suspicious file pattern detected'
-
else
-
render json: {
-
error: 'File rejected',
-
details: 'File appears to be suspicious and has been blocked'
-
}, status: :forbidden
-
return
-
end
-
end
-
-
if @upload.save
-
# Trigger plugin hooks
-
Railspress::PluginSystem.do_action('upload_created', @upload)
-
-
render json: {
-
id: @upload.id,
-
title: @upload.title,
-
filename: @upload.filename,
-
content_type: @upload.content_type,
-
file_size: @upload.file_size,
-
url: @upload.url,
-
quarantined: @upload.quarantined?,
-
created_at: @upload.created_at
-
}, status: :created
-
else
-
render json: {
-
error: 'Upload failed',
-
details: @upload.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# GET /api/v1/uploads
-
def index
-
@uploads = current_user.uploads.includes(:storage_provider)
-
-
# Filter by plugin if specified
-
if params[:plugin].present?
-
@uploads = @uploads.where("title LIKE ? OR description LIKE ?",
-
"%#{params[:plugin]}%", "%#{params[:plugin]}%")
-
end
-
-
# Filter by file type
-
if params[:file_type].present?
-
case params[:file_type]
-
when 'image'
-
@uploads = @uploads.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] })
-
when 'document'
-
@uploads = @uploads.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'] })
-
when 'archive'
-
@uploads = @uploads.joins(:file_attachment)
-
.where(active_storage_blobs: { content_type: ['application/zip', 'application/x-rar-compressed'] })
-
end
-
end
-
-
# Filter by quarantine status
-
if params[:quarantined].present?
-
@uploads = @uploads.where(quarantined: params[:quarantined] == 'true')
-
end
-
-
# Pagination
-
@uploads = @uploads.page(params[:page]).per(params[:per_page] || 20)
-
-
render json: {
-
uploads: @uploads.map do |upload|
-
{
-
id: upload.id,
-
title: upload.title,
-
description: upload.description,
-
filename: upload.filename,
-
content_type: upload.content_type,
-
file_size: upload.file_size,
-
url: upload.url,
-
quarantined: upload.quarantined?,
-
quarantine_reason: upload.quarantine_reason,
-
created_at: upload.created_at,
-
updated_at: upload.updated_at
-
}
-
end,
-
pagination: {
-
current_page: @uploads.current_page,
-
total_pages: @uploads.total_pages,
-
total_count: @uploads.total_count,
-
per_page: @uploads.limit_value
-
}
-
}
-
end
-
-
# GET /api/v1/uploads/:id
-
def show
-
@upload = current_user.uploads.find(params[:id])
-
-
render json: {
-
id: @upload.id,
-
title: @upload.title,
-
description: @upload.description,
-
filename: @upload.filename,
-
content_type: @upload.content_type,
-
file_size: @upload.file_size,
-
url: @upload.url,
-
quarantined: @upload.quarantined?,
-
quarantine_reason: @upload.quarantine_reason,
-
created_at: @upload.created_at,
-
updated_at: @upload.updated_at,
-
storage_provider: {
-
id: @upload.storage_provider.id,
-
name: @upload.storage_provider.name,
-
type: @upload.storage_provider.provider_type
-
}
-
}
-
end
-
-
# PATCH/PUT /api/v1/uploads/:id
-
def update
-
@upload = current_user.uploads.find(params[:id])
-
-
if @upload.update(upload_params.except(:file))
-
render json: {
-
id: @upload.id,
-
title: @upload.title,
-
description: @upload.description,
-
filename: @upload.filename,
-
content_type: @upload.content_type,
-
file_size: @upload.file_size,
-
url: @upload.url,
-
quarantined: @upload.quarantined?,
-
quarantine_reason: @upload.quarantine_reason,
-
updated_at: @upload.updated_at
-
}
-
else
-
render json: {
-
error: 'Update failed',
-
details: @upload.errors.full_messages
-
}, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /api/v1/uploads/:id
-
def destroy
-
@upload = current_user.uploads.find(params[:id])
-
@upload.destroy!
-
-
head :no_content
-
end
-
-
# POST /api/v1/uploads/:id/approve
-
def approve
-
@upload = current_user.uploads.find(params[:id])
-
-
if @upload.quarantined?
-
@upload.update!(quarantined: false, quarantine_reason: nil)
-
render json: { message: 'Upload approved and released from quarantine' }
-
else
-
render json: { error: 'Upload is not quarantined' }, status: :bad_request
-
end
-
end
-
-
# POST /api/v1/uploads/:id/reject
-
def reject
-
@upload = current_user.uploads.find(params[:id])
-
-
if @upload.quarantined?
-
@upload.destroy!
-
render json: { message: 'Upload rejected and deleted' }
-
else
-
render json: { error: 'Upload is not quarantined' }, status: :bad_request
-
end
-
end
-
-
private
-
-
def set_upload_security
-
@upload_security = UploadSecurity.current
-
end
-
-
def validate_upload_permissions
-
unless current_user.can_upload_files?
-
render json: { error: 'Insufficient permissions' }, status: :forbidden
-
end
-
end
-
-
def upload_params
-
params.require(:upload).permit(:title, :description, :alt_text, :file)
-
end
-
end
-
-
class ApplicationController < ActionController::Base
-
include Themeable
-
include Pundit::Authorization
-
-
# Set current tenant for multi-tenancy
-
set_current_tenant_through_filter
-
before_action :set_current_tenant
-
-
# Prevent CSRF attacks by raising an exception
-
protect_from_forgery with: :exception
-
-
# Pundit authorization
-
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
-
-
private
-
-
def set_current_tenant
-
tenant = nil
-
-
# Priority 1: Use logged-in user's tenant
-
if user_signed_in? && current_user.tenant
-
tenant = current_user.tenant
-
# Priority 2: Find tenant by domain or subdomain
-
elsif request.host != 'localhost'
-
tenant = Tenant.find_by(domain: request.host) ||
-
Tenant.find_by(subdomain: request.subdomains.first)
-
# Priority 3: Use default tenant for localhost/frontend
-
elsif !request.path.start_with?('/admin')
-
tenant = Tenant.first || Tenant.create!(
-
name: 'RailsPress Default',
-
domain: 'localhost',
-
theme: 'nordic',
-
storage_type: 'local'
-
)
-
end
-
-
# Set current tenant - use tenant_id to avoid acts_as_tenant issues
-
if tenant
-
# Create a simple object that responds to tenant_id
-
tenant_wrapper = OpenStruct.new(tenant_id: tenant.id, id: tenant.id)
-
ActsAsTenant.current_tenant = tenant_wrapper
-
end
-
-
# Store tenant in instance variable for views
-
@current_tenant = tenant
-
-
# Log tenant context
-
Rails.logger.info "Request tenant: #{tenant&.name || 'None (Global)'}" if tenant
-
end
-
-
helper_method :current_tenant
-
-
def current_tenant
-
@current_tenant || ActsAsTenant.current_tenant
-
end
-
-
def user_not_authorized
-
flash[:alert] = "You are not authorized to perform this action."
-
redirect_to(request.referrer || root_path)
-
end
-
end
-
class CommentsController < ApplicationController
-
before_action :set_commentable
-
-
def create
-
@comment = @commentable.comments.build(comment_params)
-
@comment.user = current_user if user_signed_in?
-
@comment.status = :pending
-
-
if @comment.save
-
redirect_back fallback_location: root_path, notice: 'Your comment has been submitted and is awaiting moderation.'
-
else
-
redirect_back fallback_location: root_path, alert: 'There was an error submitting your comment.'
-
end
-
end
-
-
private
-
-
def set_commentable
-
if params[:post_id]
-
@commentable = Post.friendly.find(params[:post_id])
-
elsif params[:page_id]
-
@commentable = Page.friendly.find(params[:page_id])
-
end
-
end
-
-
def comment_params
-
params.require(:comment).permit(:content, :author_name, :author_email, :author_url, :parent_id)
-
end
-
end
-
-
-
-
-
-
module LiquidRenderable
-
extend ActiveSupport::Concern
-
-
included do
-
before_action :setup_liquid_renderer
-
end
-
-
private
-
-
def setup_liquid_renderer
-
# No longer needed - we'll use ThemeVersionLoader directly
-
end
-
-
def current_theme_name
-
Railspress::ThemeLoader.current_theme || 'nordic'
-
end
-
-
def render_liquid(template, assigns = {}, options = {})
-
layout = options[:layout].nil? ? 'theme' : options[:layout]
-
-
assigns_with_context = assigns.merge(
-
current_user: current_user,
-
request_path: request.path,
-
flash: flash.to_hash,
-
params: params.to_unsafe_h,
-
assets: FrontendThemeRenderer.load_assets
-
)
-
-
# Use FrontendThemeRenderer to render from PublishedThemeVersion
-
html = FrontendThemeRenderer.render_template(template, assigns_with_context)
-
-
# Inject admin bar for logged-in users
-
if user_signed_in? && html.include?('<body')
-
admin_bar_html = render_to_string(
-
partial: 'shared/admin_bar',
-
layout: false,
-
formats: [:html]
-
)
-
-
# Inject right after <body> tag
-
html = html.sub(/(<body[^>]*>)/i, "\\1\n#{admin_bar_html}")
-
end
-
-
render html: html.html_safe, layout: false, status: options[:status] || :ok
-
end
-
-
def render_liquid_error(status_code)
-
template = case status_code
-
when 404
-
'404'
-
when 500
-
'500'
-
else
-
'error'
-
end
-
-
render_liquid(template, { status_code: status_code }, layout: 'error', status: status_code)
-
end
-
end
-
module Themeable
-
extend ActiveSupport::Concern
-
-
included do
-
before_action :load_theme
-
helper_method :current_theme, :theme_option, :theme_config
-
end
-
-
private
-
-
def load_theme
-
# Theme is already loaded by initializer, but we can add controller-specific logic here
-
@current_theme = Railspress::ThemeLoader.current_theme
-
end
-
-
def current_theme
-
@current_theme || Railspress::ThemeLoader.current_theme
-
end
-
-
def theme_option(key, default = nil)
-
config = theme_config
-
config.dig('settings', key) || default
-
end
-
-
def theme_config
-
@theme_config ||= Railspress::ThemeLoader.theme_config
-
end
-
-
def theme_name
-
theme_config['name'] || 'Default Theme'
-
end
-
-
def theme_version
-
theme_config['version'] || '1.0.0'
-
end
-
-
def theme_supports?(feature)
-
theme_config.dig('features')&.include?(feature.to_s) || false
-
end
-
end
-
-
-
-
-
-
class CspReportsController < ApplicationController
-
skip_before_action :verify_authenticity_token
-
skip_before_action :set_current_tenant
-
-
def create
-
if Rails.env.development?
-
Rails.logger.warn "CSP Violation: #{csp_report_params.inspect}"
-
end
-
-
# In production, you might want to store these or send to monitoring service
-
# CspViolation.create(report: csp_report_params) if Rails.env.production?
-
-
head :no_content
-
end
-
-
private
-
-
def csp_report_params
-
JSON.parse(request.body.read)
-
rescue JSON::ParserError
-
{}
-
end
-
end
-
-
-
-
-
-
-
-
-
class FeedsController < ApplicationController
-
before_action :set_cache_headers
-
-
# GET /feed or /feed.rss
-
def posts
-
@posts = Post.published_status.visible_to_public
-
.order(published_at: :desc)
-
.limit(50)
-
.includes(:user, :terms)
-
-
respond_to do |format|
-
format.rss { render layout: false }
-
format.atom { render layout: false }
-
format.xml { render :posts, layout: false }
-
end
-
end
-
-
# GET /feed/posts.rss
-
def posts_rss
-
posts
-
end
-
-
# GET /feed/comments.rss
-
def comments
-
@comments = Comment.where(status: 'approved')
-
.order(created_at: :desc)
-
.limit(50)
-
.includes(:commentable)
-
-
respond_to do |format|
-
format.rss { render layout: false }
-
end
-
end
-
-
# GET /feed/category/:slug.rss
-
def category
-
@category = Term.for_taxonomy('category').friendly.find(params[:slug])
-
@posts = @category.posts.published_status.visible_to_public
-
.order(published_at: :desc)
-
.limit(50)
-
.includes(:user, :taxonomies)
-
@title_suffix = "Category: #{@category.name}"
-
-
respond_to do |format|
-
format.rss { render :posts, layout: false }
-
end
-
end
-
-
# GET /feed/tag/:slug.rss
-
def tag
-
tag_taxonomy = Taxonomy.find_by!(slug: 'tag')
-
@tag = tag_taxonomy.terms.friendly.find(params[:slug])
-
@posts = Post.published_status.visible_to_public
-
.joins(:term_relationships)
-
.where(term_relationships: { term_id: @tag.id })
-
.order(published_at: :desc)
-
.distinct
-
.limit(50)
-
.includes(:user, :terms)
-
@title_suffix = "Tag: #{@tag.name}"
-
-
respond_to do |format|
-
format.rss { render :posts, layout: false }
-
end
-
end
-
-
# GET /feed/author/:id.rss
-
def author
-
@user = User.find(params[:id])
-
@posts = @user.posts.published_status.visible_to_public
-
.order(published_at: :desc)
-
.limit(50)
-
.includes(:user, :terms)
-
@title_suffix = "Author: #{@user.name || @user.email}"
-
-
respond_to do |format|
-
format.rss { render :posts, layout: false }
-
end
-
end
-
-
# GET /feed/pages.rss
-
def pages
-
@pages = Page.published_status.visible_to_public
-
.order(published_at: :desc)
-
.limit(50)
-
-
respond_to do |format|
-
format.rss { render layout: false }
-
end
-
end
-
-
private
-
-
def set_cache_headers
-
# Cache RSS feeds for 1 hour
-
expires_in 1.hour, public: true
-
end
-
end
-
-
-
-
# frozen_string_literal: true
-
-
class GdprController < ApplicationController
-
before_action :set_cors_headers
-
before_action :validate_gdpr_request, only: [:data_access, :data_deletion, :data_portability]
-
-
# GET /gdpr/privacy-policy
-
def privacy_policy
-
@privacy_info = GdprComplianceService.get_privacy_policy_info
-
-
respond_to do |format|
-
format.html { render layout: 'application' }
-
format.json { render json: @privacy_info }
-
end
-
end
-
-
# POST /gdpr/consent
-
def update_consent
-
session_id = get_or_create_session_id
-
consent_data = params[:consent] || {}
-
-
# Validate consent data
-
if consent_data.empty?
-
render json: { error: 'Consent data is required' }, status: :bad_request
-
return
-
end
-
-
# Store consent
-
GdprComplianceService.store_consent(session_id, consent_data)
-
-
# Set consent cookies
-
set_consent_cookies(consent_data)
-
-
render json: {
-
success: true,
-
message: 'Consent preferences updated',
-
session_id: session_id
-
}
-
rescue => e
-
Rails.logger.error "Failed to update consent: #{e.message}"
-
render json: { error: 'Failed to update consent preferences' }, status: :internal_server_error
-
end
-
-
# POST /gdpr/data-access
-
def data_access
-
session_id = get_or_create_session_id
-
request_data = {
-
request_type: 'data_access',
-
timestamp: Time.current,
-
ip_address: request.ip,
-
user_agent: request.user_agent
-
}
-
-
# Handle data access request
-
data = GdprComplianceService.handle_data_access_request(session_id, request_data)
-
-
respond_to do |format|
-
format.json { render json: data }
-
format.html {
-
# For HTML requests, redirect to download page
-
redirect_to gdpr_download_path(session_id: session_id)
-
}
-
end
-
rescue => e
-
Rails.logger.error "Failed to handle data access request: #{e.message}"
-
render json: { error: 'Failed to process data access request' }, status: :internal_server_error
-
end
-
-
# POST /gdpr/data-deletion
-
def data_deletion
-
session_id = get_or_create_session_id
-
request_data = {
-
request_type: 'data_deletion',
-
timestamp: Time.current,
-
ip_address: request.ip,
-
user_agent: request.user_agent
-
}
-
-
# Handle data deletion request
-
result = GdprComplianceService.handle_data_deletion_request(session_id, request_data)
-
-
render json: result
-
rescue => e
-
Rails.logger.error "Failed to handle data deletion request: #{e.message}"
-
render json: { error: 'Failed to process data deletion request' }, status: :internal_server_error
-
end
-
-
# POST /gdpr/data-portability
-
def data_portability
-
session_id = get_or_create_session_id
-
request_data = {
-
request_type: 'data_portability',
-
timestamp: Time.current,
-
ip_address: request.ip,
-
user_agent: request.user_agent
-
}
-
-
# Handle data portability request
-
data = GdprComplianceService.handle_data_portability_request(session_id, request_data)
-
-
respond_to do |format|
-
format.json { render json: data }
-
format.html {
-
# For HTML requests, redirect to download page
-
redirect_to gdpr_download_path(session_id: session_id, format: :json)
-
}
-
end
-
rescue => e
-
Rails.logger.error "Failed to handle data portability request: #{e.message}"
-
render json: { error: 'Failed to process data portability request' }, status: :internal_server_error
-
end
-
-
# GET /gdpr/download/:session_id
-
def download_data
-
session_id = params[:session_id]
-
-
if session_id.blank?
-
render json: { error: 'Session ID is required' }, status: :bad_request
-
return
-
end
-
-
# Get data for download
-
data = GdprComplianceService.handle_data_access_request(session_id)
-
-
respond_to do |format|
-
format.json {
-
send_data JSON.pretty_generate(data),
-
filename: "railspress_data_#{session_id}_#{Date.current}.json",
-
type: 'application/json'
-
}
-
format.html {
-
@data = data
-
@session_id = session_id
-
render layout: 'application'
-
}
-
end
-
rescue => e
-
Rails.logger.error "Failed to download data: #{e.message}"
-
render json: { error: 'Failed to download data' }, status: :internal_server_error
-
end
-
-
# POST /gdpr/contact-dpo
-
def contact_dpo
-
# This would typically send an email to the DPO
-
# For now, we'll just log the request
-
-
session_id = get_or_create_session_id
-
request_data = {
-
request_type: 'dpo_contact',
-
timestamp: Time.current,
-
ip_address: request.ip,
-
user_agent: request.user_agent,
-
message: params[:message],
-
email: params[:email]
-
}
-
-
# Log the DPO contact request
-
AnalyticsEvent.create!(
-
event_name: 'gdpr_dpo_contact_request',
-
properties: request_data,
-
session_id: session_id,
-
tenant: ActsAsTenant.current_tenant
-
)
-
-
render json: {
-
success: true,
-
message: 'Your message has been sent to our Data Protection Officer',
-
dpo_email: SiteSetting.get('dpo_email', 'dpo@railspress.com')
-
}
-
rescue => e
-
Rails.logger.error "Failed to contact DPO: #{e.message}"
-
render json: { error: 'Failed to send message to DPO' }, status: :internal_server_error
-
end
-
-
# GET /gdpr/consent-status
-
def consent_status
-
session_id = get_or_create_session_id
-
-
consent_status = {
-
session_id: session_id,
-
analytics_consent: GdprComplianceService.has_valid_consent?(session_id, 'analytics'),
-
marketing_consent: GdprComplianceService.has_valid_consent?(session_id, 'marketing'),
-
essential_consent: true, # Always true for essential cookies
-
gdpr_applies: GdprComplianceService.gdpr_applies?(request),
-
privacy_policy_url: gdpr_privacy_policy_url
-
}
-
-
render json: consent_status
-
end
-
-
private
-
-
def set_cors_headers
-
headers['Access-Control-Allow-Origin'] = '*'
-
headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
-
headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-CSRF-Token'
-
end
-
-
def validate_gdpr_request
-
# Check if GDPR applies
-
unless GdprComplianceService.gdpr_applies?(request)
-
render json: { error: 'GDPR does not apply to this request' }, status: :forbidden
-
return
-
end
-
-
# Check rate limiting (prevent abuse)
-
session_id = get_or_create_session_id
-
rate_limit_key = "gdpr_request_rate_limit:#{session_id}"
-
-
if Rails.cache.read(rate_limit_key)
-
render json: { error: 'Too many requests. Please try again later.' }, status: :too_many_requests
-
return
-
end
-
-
# Set rate limit (5 requests per hour)
-
Rails.cache.write(rate_limit_key, true, expires_in: 1.hour)
-
end
-
-
def get_or_create_session_id
-
session_id = cookies[:analytics_session_id]
-
-
if session_id.blank?
-
session_id = generate_session_id
-
cookies[:analytics_session_id] = {
-
value: session_id,
-
expires: 1.year.from_now,
-
httponly: true,
-
secure: Rails.env.production?,
-
same_site: :lax
-
}
-
end
-
-
session_id
-
end
-
-
def generate_session_id
-
SecureRandom.hex(16)
-
end
-
-
def set_consent_cookies(consent_data)
-
consent_data.each do |consent_type, granted|
-
cookie_name = "analytics_consent_#{consent_type}"
-
cookies[cookie_name] = {
-
value: granted.to_s,
-
expires: 1.year.from_now,
-
httponly: true,
-
secure: Rails.env.production?,
-
same_site: :lax
-
}
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
class GraphqlController < ApplicationController
-
# If accessing from outside this domain, nullify the session
-
# This allows for outside API access while preventing CSRF attacks,
-
# but you'll have to authenticate your user separately
-
# protect_from_forgery with: :null_session
-
-
def execute
-
variables = prepare_variables(params[:variables])
-
query = params[:query]
-
operation_name = params[:operationName]
-
context = {
-
# Query context goes here, for example:
-
# current_user: current_user,
-
}
-
result = RailspressSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
-
render json: result
-
rescue StandardError => e
-
raise e unless Rails.env.development?
-
handle_error_in_development(e)
-
end
-
-
private
-
-
# Handle variables in form data, JSON body, or a blank value
-
def prepare_variables(variables_param)
-
case variables_param
-
when String
-
if variables_param.present?
-
JSON.parse(variables_param) || {}
-
else
-
{}
-
end
-
when Hash
-
variables_param
-
when ActionController::Parameters
-
variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables.
-
when nil
-
{}
-
else
-
raise ArgumentError, "Unexpected parameter: #{variables_param}"
-
end
-
end
-
-
def handle_error_in_development(e)
-
logger.error e.message
-
logger.error e.backtrace.join("\n")
-
-
render json: { errors: [{ message: e.message, backtrace: e.backtrace }], data: {} }, status: 500
-
end
-
end
-
class HomeController < ApplicationController
-
-
def index
-
# Get the active theme
-
active_theme = Theme.active.first
-
unless active_theme
-
render html: "<h1>No active theme found</h1>", status: :internal_server_error
-
return
-
end
-
-
# Ensure theme has a published version
-
active_theme.ensure_published_version_exists!
-
published_version = active_theme.published_version
-
-
unless published_version
-
render html: "<h1>Failed to create published theme version</h1>", status: :internal_server_error
-
return
-
end
-
-
# Prepare context data
-
featured_posts = Post.published.recent.limit(3).to_a
-
recent_posts = Post.published.recent.limit(6).to_a
-
categories = Term.for_taxonomy('category').limit(10).to_a
-
-
context = {
-
'featured_posts' => featured_posts,
-
'recent_posts' => recent_posts,
-
'posts' => recent_posts, # Add posts for Nordic theme
-
'collections' => {
-
'posts' => recent_posts # Add collections.posts for Nordic theme
-
},
-
'categories' => categories,
-
'template' => 'index',
-
'page' => {
-
'title' => SiteSetting.get('site_title', 'RailsPress'),
-
'seo_title' => SiteSetting.get('site_title', 'RailsPress'),
-
'url' => request.url,
-
'featured_image' => nil
-
},
-
'site' => {
-
'title' => SiteSetting.get('site_title', 'RailsPress'),
-
'description' => SiteSetting.get('site_description', 'Built with RailsPress'),
-
'settings' => {
-
'comments_enabled' => SiteSetting.get('comments_enabled', true),
-
'comments_moderation' => SiteSetting.get('comments_moderation', true),
-
'comment_registration_required' => SiteSetting.get('comment_registration_required', false),
-
'close_comments_after_days' => SiteSetting.get('close_comments_after_days', 0),
-
'show_avatars' => SiteSetting.get('show_avatars', true),
-
'akismet_enabled' => SiteSetting.get('akismet_enabled', false),
-
'akismet_api_key' => SiteSetting.get('akismet_api_key', '')
-
}
-
},
-
'request' => {
-
'url' => request.url,
-
'params' => request.params
-
},
-
'current_user' => user_signed_in? ? current_user : nil
-
}
-
-
-
# Use FrontendRendererService for proper rendering
-
renderer = FrontendRendererService.new(published_version)
-
-
begin
-
# Check if user is logged in for admin bar
-
show_admin_bar = user_signed_in?
-
-
# Add admin bar to context if user is logged in
-
if show_admin_bar
-
context['show_admin_bar'] = true
-
context['current_user'] = current_user
-
end
-
-
# Use FrontendRendererService to render the complete page
-
html = renderer.render_template('index', context)
-
assets = renderer.assets
-
-
# Inject admin bar and assets into the rendered HTML
-
if show_admin_bar
-
# Add admin bar at the top
-
admin_bar_html = render_to_string(partial: 'shared/admin_bar')
-
-
# Add admin bar CSS
-
admin_bar_css = <<~CSS
-
<style>
-
body { padding-top: 32px; } /* Make room for admin bar */
-
</style>
-
CSS
-
-
# Inject admin bar CSS into head
-
html = html.gsub(/<\/head>/i, "#{admin_bar_css}</head>")
-
-
# Inject admin bar after opening body tag
-
html = html.gsub(/<body[^>]*>/i) { |match| "#{match}\n#{admin_bar_html}" }
-
end
-
-
# Inject theme assets if not already present
-
if assets[:css].present? && !html.include?('</style>')
-
css_injection = "<style>#{assets[:css]}</style>"
-
html = html.gsub(/<\/head>/i, "#{css_injection}</head>")
-
end
-
-
if assets[:js].present? && !html.include?('</script>')
-
js_injection = "<script>#{assets[:js]}</script>"
-
html = html.gsub(/<\/body>/i, "#{js_injection}</body>")
-
end
-
-
# Render the complete HTML directly
-
render html: html.html_safe
-
rescue => e
-
Rails.logger.error "Homepage rendering failed: #{e.message}"
-
render html: "<h1>Rendering Error: #{e.message}</h1>", status: :internal_server_error
-
end
-
end
-
end
-
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
-
# Handle Google OAuth callback
-
def google_oauth2
-
handle_oauth_callback('google_oauth2')
-
end
-
-
# Handle GitHub OAuth callback
-
def github
-
handle_oauth_callback('github')
-
end
-
-
# Handle Facebook OAuth callback
-
def facebook
-
handle_oauth_callback('facebook')
-
end
-
-
# Handle Twitter OAuth callback
-
def twitter
-
handle_oauth_callback('twitter')
-
end
-
-
private
-
-
def handle_oauth_callback(provider)
-
auth_data = request.env['omniauth.auth']
-
-
if auth_data.blank?
-
# Determine redirect path based on request path
-
redirect_path = request.path.include?('/admin/') ? new_admin_user_session_path : new_user_session_path
-
redirect_to redirect_path, alert: 'Authentication failed. Please try again.'
-
return
-
end
-
-
# Find or create user based on OAuth data
-
user = find_or_create_user_from_oauth(auth_data, provider)
-
-
if user.persisted?
-
sign_in_and_redirect user, event: :authentication
-
set_flash_message(:notice, :success, kind: provider.humanize) if is_navigational_format?
-
else
-
session["devise.#{provider}_data"] = auth_data.except('extra')
-
# Determine redirect path based on request path
-
redirect_path = request.path.include?('/admin/') ? new_admin_user_session_path : new_user_registration_path
-
redirect_to redirect_path, alert: user.errors.full_messages.join(', ')
-
end
-
end
-
-
def find_or_create_user_from_oauth(auth_data, provider)
-
# Extract user information from OAuth data
-
email = extract_email(auth_data)
-
name = extract_name(auth_data)
-
uid = auth_data.uid
-
-
# Check if OAuth requires email and we don't have one
-
if SiteSetting.get('oauth_require_email', true) && email.blank?
-
return User.new.tap { |u| u.errors.add(:email, 'Email is required for OAuth authentication') }
-
end
-
-
# Try to find existing user by email first
-
user = User.find_by(email: email) if email.present?
-
-
if user
-
# User exists - check if we should allow linking
-
if SiteSetting.get('oauth_allow_existing_users', true)
-
# Link OAuth account to existing user
-
link_oauth_account(user, auth_data, provider)
-
return user
-
else
-
return User.new.tap { |u| u.errors.add(:base, 'OAuth authentication not allowed for existing users') }
-
end
-
end
-
-
# Try to find user by OAuth provider and UID
-
oauth_account = OauthAccount.find_by(provider: provider, uid: uid)
-
if oauth_account
-
return oauth_account.user
-
end
-
-
# Create new user if auto-registration is enabled
-
if SiteSetting.get('oauth_auto_register', true)
-
create_user_from_oauth(auth_data, provider, email, name, uid)
-
else
-
User.new.tap { |u| u.errors.add(:base, 'Auto-registration is disabled') }
-
end
-
end
-
-
def create_user_from_oauth(auth_data, provider, email, name, uid)
-
# Generate a random password for OAuth users
-
password = Devise.friendly_token[0, 20]
-
-
user = User.new(
-
email: email,
-
name: name,
-
password: password,
-
password_confirmation: password,
-
role: SiteSetting.get('oauth_default_role', 'subscriber')
-
)
-
-
if user.save
-
# Create OAuth account record
-
OauthAccount.create!(
-
user: user,
-
provider: provider,
-
uid: uid,
-
email: email,
-
name: name,
-
avatar_url: extract_avatar_url(auth_data)
-
)
-
-
user
-
else
-
user
-
end
-
end
-
-
def link_oauth_account(user, auth_data, provider)
-
uid = auth_data.uid
-
email = extract_email(auth_data)
-
name = extract_name(auth_data)
-
-
# Check if OAuth account already exists
-
oauth_account = OauthAccount.find_by(provider: provider, uid: uid)
-
-
if oauth_account.nil?
-
# Create new OAuth account link
-
OauthAccount.create!(
-
user: user,
-
provider: provider,
-
uid: uid,
-
email: email,
-
name: name,
-
avatar_url: extract_avatar_url(auth_data)
-
)
-
end
-
end
-
-
def extract_email(auth_data)
-
auth_data.info.email.presence
-
end
-
-
def extract_name(auth_data)
-
name = auth_data.info.name.presence
-
name ||= "#{auth_data.info.first_name} #{auth_data.info.last_name}".strip.presence
-
name ||= auth_data.info.nickname.presence
-
name ||= 'OAuth User'
-
end
-
-
def extract_avatar_url(auth_data)
-
auth_data.info.image.presence
-
end
-
end
-
class PagesController < ApplicationController
-
include LiquidRenderable
-
-
def show
-
path = params[:path]
-
page = Page.friendly.find(path.split('/').last)
-
-
# Check if visible (handles all statuses)
-
unless page.visible_to_public? || can_view_page?(page)
-
raise ActiveRecord::RecordNotFound
-
end
-
-
# Auto-publish scheduled pages
-
page.check_scheduled_publish
-
-
# Check password protection
-
if page.password_protected? && !password_verified?(page)
-
return render_liquid('password_protected', { 'page' => page })
-
end
-
-
comments = page.comments.approved.root_comments.order(created_at: :desc)
-
-
# Determine template (check for specialized template like page.about)
-
# Use page slug to determine if there's a specialized template
-
custom_template_path = Rails.root.join('app', 'themes', current_theme_name, 'templates', "page.#{page.slug}.json")
-
template_name = File.exist?(custom_template_path) ? "page.#{page.slug}" : 'page'
-
-
render_liquid(template_name, {
-
'page' => {
-
'title' => page.title,
-
'content' => page.content.to_s,
-
'description' => page.respond_to?(:excerpt) ? page.excerpt : page.content.to_s.truncate(200),
-
'featured_image' => page.respond_to?(:featured_image_url) ? page.featured_image_url : nil,
-
'slug' => page.slug,
-
'author' => page.user,
-
'published_at' => page.published_at,
-
'updated_at' => page.updated_at
-
},
-
'comments' => comments,
-
'template' => 'page'
-
})
-
rescue ActiveRecord::RecordNotFound
-
render_liquid_error(404)
-
end
-
-
# POST /pages/:path/verify_password
-
def verify_password
-
path = params[:path]
-
@page = Page.friendly.find(path.split('/').last)
-
-
if @page.password_matches?(params[:password])
-
# Store verified page ID in session
-
session[:verified_pages] ||= []
-
session[:verified_pages] << @page.id unless session[:verified_pages].include?(@page.id)
-
-
redirect_to page_path(@page.slug), notice: 'Password verified successfully.'
-
else
-
redirect_to page_path(@page.slug), alert: 'Incorrect password. Please try again.'
-
end
-
end
-
-
private
-
-
def can_view_page?(page)
-
return false unless user_signed_in?
-
-
# Admins and editors can view everything
-
return true if current_user.administrator? || current_user.editor?
-
-
# Authors can view their own pages
-
return true if page.user_id == current_user.id
-
-
# Private pages visible to any logged-in user
-
return true if page.private_page_status?
-
-
false
-
end
-
-
def password_verified?(page)
-
return true unless page.password_protected?
-
return true if can_view_page?(page) # Admins/editors/authors bypass password
-
-
session[:verified_pages]&.include?(page.id)
-
end
-
end
-
# SlickForms Public Forms Controller
-
# Handles public form display and submission
-
-
class Plugins::SlickForms::FormsController < ApplicationController
-
before_action :set_form, only: [:show, :embed]
-
-
def show
-
# Display public form
-
render layout: 'application'
-
end
-
-
def embed
-
# Display form for embedding in other sites
-
render layout: false
-
end
-
-
private
-
-
def set_form
-
@form = get_form_by_id(params[:form_id])
-
redirect_to root_path, alert: 'Form not found.' unless @form
-
end
-
-
def get_form_by_id(id)
-
return nil unless table_exists?('slick_forms')
-
result = ActiveRecord::Base.connection.execute(
-
"SELECT * FROM slick_forms WHERE id = #{id} AND active = 1"
-
).first
-
result&.symbolize_keys
-
end
-
-
def table_exists?(table_name)
-
ActiveRecord::Base.connection.table_exists?(table_name)
-
end
-
end
-
-
-
-
-
# SlickForms Public Submissions Controller
-
# Handles public form submissions
-
-
class Plugins::SlickForms::SubmissionsController < ApplicationController
-
protect_from_forgery with: :null_session
-
-
def create
-
form_id = params[:form_id]
-
form_data = submission_params
-
-
# Get the form to validate
-
@form = get_form_by_id(form_id)
-
-
unless @form
-
render json: { error: 'Form not found' }, status: :not_found
-
return
-
end
-
-
# Process the submission
-
if process_submission(form_id, form_data)
-
render json: {
-
success: true,
-
message: 'Thank you for your submission!',
-
redirect_url: @form[:settings]['success_redirect_url']
-
}
-
else
-
render json: {
-
success: false,
-
error: 'Failed to process submission'
-
}, status: :unprocessable_entity
-
end
-
end
-
-
private
-
-
def submission_params
-
params.except(:controller, :action, :form_id).permit!
-
end
-
-
def get_form_by_id(id)
-
return nil unless table_exists?('slick_forms')
-
result = ActiveRecord::Base.connection.execute(
-
"SELECT * FROM slick_forms WHERE id = #{id} AND active = 1"
-
).first
-
result&.symbolize_keys
-
end
-
-
def process_submission(form_id, data)
-
# Get the plugin instance to use its processing logic
-
plugin = Railspress::PluginSystem.get_plugin('slick_forms')
-
-
if plugin
-
# Use plugin's spam protection and validation
-
return false if plugin.send(:detect_spam, data)
-
return false unless plugin.send(:validate_unique_entries, data)
-
end
-
-
# Save submission to database
-
save_submission(form_id, data)
-
-
# Send email notification if configured
-
send_notification(form_id, data) if should_send_notification?
-
-
true
-
rescue => e
-
Rails.logger.error "Failed to process submission: #{e.message}"
-
false
-
end
-
-
def save_submission(form_id, data)
-
return false unless table_exists?('slick_form_submissions')
-
-
ActiveRecord::Base.connection.execute(
-
"INSERT INTO slick_form_submissions (slick_form_id, data, ip_address, user_agent, referrer, spam, created_at, updated_at) VALUES (#{form_id}, '#{data.to_json}', '#{request.remote_ip}', '#{request.user_agent}', '#{request.referer}', 0, NOW(), NOW())"
-
)
-
-
# Update form submission count
-
ActiveRecord::Base.connection.execute(
-
"UPDATE slick_forms SET submissions_count = submissions_count + 1 WHERE id = #{form_id}"
-
) if table_exists?('slick_forms')
-
-
true
-
end
-
-
def send_notification(form_id, data)
-
# This would integrate with the plugin's notification system
-
Rails.logger.info "Sending notification for form #{form_id}"
-
end
-
-
def should_send_notification?
-
# Check plugin settings for email notifications
-
plugin = Railspress::PluginSystem.get_plugin('slick_forms')
-
return false unless plugin
-
-
plugin.get_setting(:enable_notifications, true)
-
end
-
-
def table_exists?(table_name)
-
ActiveRecord::Base.connection.table_exists?(table_name)
-
end
-
end
-
-
-
-
-
class PostsController < ApplicationController
-
include LiquidRenderable
-
-
def index
-
posts = Post.visible_to_public.recent.includes(:user, :categories, :tags).page(params[:page])
-
title = "Blog"
-
-
render_liquid('blog', {
-
'posts' => posts,
-
'title' => title,
-
'template' => 'blog',
-
'paginate' => {
-
'current_page' => posts.current_page,
-
'total_pages' => posts.total_pages,
-
'per_page' => posts.limit_value
-
}
-
})
-
end
-
-
def show
-
post = Post.friendly.find(params[:id])
-
-
# Check if visible (handles all statuses)
-
unless post.visible_to_public? || can_view_post?(post)
-
raise ActiveRecord::RecordNotFound
-
end
-
-
# Auto-publish scheduled posts
-
post.check_scheduled_publish
-
-
# Check password protection
-
if post.password_protected? && !password_verified?(post)
-
return render_liquid('password_protected', { 'post' => post })
-
end
-
-
# Use Related Posts plugin if available, otherwise fallback to basic logic
-
category_taxonomy = Taxonomy.find_by(slug: 'category')
-
post_categories = category_taxonomy ? post.terms.where(taxonomy: category_taxonomy) : []
-
-
related_posts = if defined?(RelatedPosts)
-
RelatedPosts.find_related(post, 3)
-
elsif post_categories.any?
-
term_ids = post_categories.pluck(:id)
-
Post.visible_to_public
-
.joins(:term_relationships)
-
.where(term_relationships: { term_id: term_ids })
-
.where.not(id: post.id)
-
.distinct
-
.limit(3)
-
else
-
[]
-
end
-
-
comments = post.comments.approved.root_comments.order(created_at: :desc)
-
-
# Trigger post viewed hook for analytics plugins
-
Railspress::PluginSystem.do_action('post_viewed', post.id) if defined?(Railspress::PluginSystem)
-
-
render_liquid('post', {
-
'post' => post,
-
'page' => {
-
'title' => post.title,
-
'description' => post.respond_to?(:excerpt) ? post.excerpt : post.content.to_s.truncate(200),
-
'featured_image' => post.respond_to?(:featured_image_url) ? post.featured_image_url : nil,
-
'type' => 'article',
-
'schema_type' => 'Article',
-
'author' => post.user,
-
'published_at' => post.published_at,
-
'updated_at' => post.updated_at,
-
'categories' => post.terms.joins(:taxonomy).where(taxonomies: { slug: 'category' }).to_a,
-
'tags' => post.terms.joins(:taxonomy).where(taxonomies: { slug: 'tag' }).to_a
-
},
-
'site' => {
-
'title' => SiteSetting.get('site_title', 'RailsPress'),
-
'description' => SiteSetting.get('site_description', 'Built with RailsPress'),
-
'settings' => {
-
'comments_enabled' => SiteSetting.get('comments_enabled', true),
-
'comments_moderation' => SiteSetting.get('comments_moderation', true),
-
'comment_registration_required' => SiteSetting.get('comment_registration_required', false),
-
'close_comments_after_days' => SiteSetting.get('close_comments_after_days', 0),
-
'show_avatars' => SiteSetting.get('show_avatars', true),
-
'akismet_enabled' => SiteSetting.get('akismet_enabled', false),
-
'akismet_api_key' => SiteSetting.get('akismet_api_key', '')
-
}
-
},
-
'related_posts' => related_posts.to_a,
-
'comments' => comments.to_a,
-
'current_user' => user_signed_in? ? current_user : nil,
-
'template' => 'post'
-
})
-
end
-
-
# POST /blog/:id/verify_password
-
def verify_password
-
@post = Post.friendly.find(params[:id])
-
-
if @post.password_matches?(params[:password])
-
# Store verified post ID in session
-
session[:verified_posts] ||= []
-
session[:verified_posts] << @post.id unless session[:verified_posts].include?(@post.id)
-
-
redirect_to blog_post_path(@post), notice: 'Password verified successfully.'
-
else
-
redirect_to blog_post_path(@post), alert: 'Incorrect password. Please try again.'
-
end
-
end
-
-
def category
-
category = Term.for_taxonomy('category').friendly.find(params[:slug])
-
posts = category.posts.visible_to_public.recent.page(params[:page])
-
-
render_liquid('category', {
-
'category' => category,
-
'posts' => posts,
-
'title' => "Category: #{category.name}",
-
'page' => {
-
'title' => "Category: #{category.name}",
-
'description' => category.description
-
},
-
'template' => 'category',
-
'paginate' => {
-
'current_page' => posts.current_page,
-
'total_pages' => posts.total_pages,
-
'per_page' => posts.limit_value
-
}
-
})
-
end
-
-
def tag
-
tag = Term.for_taxonomy('post_tag').friendly.find(params[:slug])
-
posts = tag.posts.visible_to_public.recent.page(params[:page])
-
-
render_liquid('tag', {
-
'tag' => tag,
-
'posts' => posts,
-
'title' => "Tag: #{tag.name}",
-
'page' => {
-
'title' => "Tag: #{tag.name}",
-
'description' => tag.description
-
},
-
'template' => 'tag',
-
'paginate' => {
-
'current_page' => posts.current_page,
-
'total_pages' => posts.total_pages,
-
'per_page' => posts.limit_value
-
}
-
})
-
end
-
-
def archive
-
year = params[:year].to_i
-
month = params[:month]&.to_i
-
-
posts = Post.visible_to_public
-
-
# SQLite-compatible date filtering
-
if month
-
start_date = Date.new(year, month, 1)
-
end_date = start_date.end_of_month
-
else
-
start_date = Date.new(year, 1, 1)
-
end_date = Date.new(year, 12, 31)
-
end
-
-
posts = posts.where(published_at: start_date.beginning_of_day..end_date.end_of_day)
-
posts = posts.recent.page(params[:page])
-
-
title = month ? "Archive: #{Date::MONTHNAMES[month]} #{year}" : "Archive: #{year}"
-
-
render_liquid('archive', {
-
'posts' => posts,
-
'title' => title,
-
'year' => year,
-
'month' => month,
-
'page' => {
-
'title' => title
-
},
-
'template' => 'archive',
-
'paginate' => {
-
'current_page' => posts.current_page,
-
'total_pages' => posts.total_pages,
-
'per_page' => posts.limit_value
-
}
-
})
-
end
-
-
def search
-
query = params[:q]
-
posts = query.present? ? Post.visible_to_public.search(query).recent.page(params[:page]) : Post.none
-
-
render_liquid('search', {
-
'posts' => posts,
-
'query' => query,
-
'title' => "Search results for: #{query}",
-
'page' => {
-
'title' => "Search results for: #{query}"
-
},
-
'template' => 'search',
-
'paginate' => posts.any? ? {
-
'current_page' => posts.current_page,
-
'total_pages' => posts.total_pages,
-
'per_page' => posts.limit_value
-
} : {}
-
})
-
end
-
-
private
-
-
def can_view_post?(post)
-
return false unless user_signed_in?
-
-
# Admins and editors can view everything
-
return true if current_user.administrator? || current_user.editor?
-
-
# Authors can view their own posts
-
return true if post.user_id == current_user.id
-
-
# Private posts visible to any logged-in user
-
return true if post.private_post_status?
-
-
false
-
end
-
-
def password_verified?(post)
-
return true unless post.password_protected?
-
return true if can_view_post?(post) # Admins/editors/authors bypass password
-
-
session[:verified_posts]&.include?(post.id)
-
end
-
end
-
class PreviewController < ApplicationController
-
skip_before_action :verify_authenticity_token
-
before_action :set_published_version
-
before_action :set_renderer
-
-
# GET /preview/:template_name
-
def show
-
template_name = params[:template_name] || 'index'
-
-
begin
-
@preview_html = @renderer.render_template(template_name, preview_context)
-
@assets = @renderer.assets
-
-
render layout: 'preview'
-
rescue => e
-
Rails.logger.error "Preview rendering failed: #{e.message}"
-
@preview_html = "<div style='padding: 20px; color: red;'>Preview Error: #{e.message}</div>"
-
@assets = { css: '', js: '' }
-
render layout: 'preview'
-
end
-
end
-
-
private
-
-
def set_published_version
-
# Get the latest PublishedThemeVersion for the active theme
-
@active_theme = Theme.active_theme
-
return render_404 unless @active_theme
-
-
@published_version = PublishedThemeVersion.where(theme_name: @active_theme.name.underscore).latest.first
-
return render_404 unless @published_version
-
end
-
-
def set_renderer
-
@renderer = FrontendRendererService.new(@published_version)
-
end
-
-
def preview_context
-
{
-
current_user: current_user,
-
request: request
-
}
-
end
-
-
def render_404
-
render plain: 'Theme not found', status: :not_found
-
end
-
end
-
# Fluent Forms Controller
-
# Handles form submissions and frontend form rendering
-
-
class FluentFormsController < ApplicationController
-
skip_before_action :verify_authenticity_token, only: [:submit], if: -> { request.format.json? }
-
before_action :set_form, only: [:show, :submit]
-
-
# GET /fluent-forms/:id
-
def show
-
render plain: render_form(@form)
-
end
-
-
# POST /fluent-forms/submit
-
def submit
-
unless @form
-
return respond_with_error('Form not found', 404)
-
end
-
-
# Check if form is active
-
if @form[:status] != 'published'
-
return respond_with_error('Form is not available', 403)
-
end
-
-
# Validate form data
-
validation_result = validate_submission
-
unless validation_result[:valid]
-
return respond_with_error(validation_result[:errors].join(', '), 422)
-
end
-
-
# Check spam protection
-
if spam_detected?
-
log_spam_attempt
-
return respond_with_success('Thank you for your submission!')
-
end
-
-
# Create submission
-
submission_data = prepare_submission_data
-
submission_id = FluentFormsPro.create_submission(
-
@form[:id],
-
submission_data,
-
current_user&.id
-
)
-
-
if submission_id
-
# Store entry details
-
store_entry_details(submission_id)
-
-
# Handle file uploads
-
handle_file_uploads(submission_id) if has_file_uploads?
-
-
# Process payment if required
-
if @form[:has_payment]
-
payment_result = process_payment(submission_id)
-
return respond_with_error(payment_result[:error], 422) unless payment_result[:success]
-
end
-
-
respond_with_success(
-
@form[:settings][:confirmation][:messageToShow] || 'Thank you for your submission!',
-
submission_id
-
)
-
else
-
respond_with_error('Failed to save submission', 500)
-
end
-
rescue => e
-
Rails.logger.error "[Fluent Forms] Submission error: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
respond_with_error('An error occurred while processing your submission', 500)
-
end
-
-
private
-
-
def set_form
-
form_id = params[:id] || params[:form_id]
-
@form = get_form_data(form_id) if form_id
-
end
-
-
def get_form_data(form_id)
-
plugin = FluentFormsPro.new
-
plugin.get_form(form_id)
-
end
-
-
def render_form(form)
-
FluentFormsRenderer.new(form).render
-
end
-
-
def validate_submission
-
errors = []
-
fields = @form[:form_fields][:fields] || []
-
-
fields.each do |field|
-
field_name = field.dig(:attributes, :name)
-
next unless field_name
-
-
validation_rules = field.dig(:settings, :validation_rules) || {}
-
field_value = params[field_name]
-
-
# Check required fields
-
if validation_rules.dig(:required, :value)
-
if field_value.blank?
-
message = validation_rules.dig(:required, :message) || "#{field.dig(:settings, :label)} is required"
-
errors << message
-
end
-
end
-
-
# Email validation
-
if validation_rules.dig(:email, :value) && field_value.present?
-
unless valid_email?(field_value)
-
message = validation_rules.dig(:email, :message) || 'Please enter a valid email'
-
errors << message
-
end
-
end
-
-
# Min/Max length
-
if field_value.present?
-
min_length = validation_rules.dig(:min_length, :value)
-
max_length = validation_rules.dig(:max_length, :value)
-
-
if min_length && field_value.length < min_length.to_i
-
errors << validation_rules.dig(:min_length, :message) || "Minimum length is #{min_length}"
-
end
-
-
if max_length && field_value.length > max_length.to_i
-
errors << validation_rules.dig(:max_length, :message) || "Maximum length is #{max_length}"
-
end
-
end
-
end
-
-
{
-
valid: errors.empty?,
-
errors: errors
-
}
-
end
-
-
def valid_email?(email)
-
email.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
-
end
-
-
def spam_detected?
-
plugin = FluentFormsPro.new
-
-
# Honeypot check
-
if plugin.setting_enabled?('honeypot_enabled')
-
return true if params[:_ff_honeypot].present?
-
end
-
-
# reCAPTCHA check
-
if plugin.setting_enabled?('recaptcha_enabled')
-
recaptcha_token = params[:recaptcha_token]
-
return true unless verify_recaptcha(recaptcha_token)
-
end
-
-
false
-
end
-
-
def verify_recaptcha(token)
-
return true unless token
-
-
plugin = FluentFormsPro.new
-
secret_key = plugin.get_setting('recaptcha_secret_key')
-
return true if secret_key.blank?
-
-
# Verify with Google reCAPTCHA API
-
uri = URI('https://www.google.com/recaptcha/api/siteverify')
-
response = Net::HTTP.post_form(uri, {
-
secret: secret_key,
-
response: token,
-
remoteip: request.remote_ip
-
})
-
-
result = JSON.parse(response.body)
-
result['success'] == true
-
rescue => e
-
Rails.logger.error "[Fluent Forms] reCAPTCHA verification error: #{e.message}"
-
true # Allow submission on verification error
-
end
-
-
def log_spam_attempt
-
ActiveRecord::Base.connection.execute(
-
"INSERT INTO ff_logs (form_id, log_type, title, description, created_at)
-
VALUES (?, ?, ?, ?, ?)",
-
@form[:id],
-
'spam',
-
'Spam submission blocked',
-
"IP: #{request.remote_ip}",
-
Time.current
-
)
-
end
-
-
def prepare_submission_data
-
{
-
response_data: collect_form_data,
-
source_url: request.referrer || request.original_url,
-
browser: request.user_agent,
-
device: detect_device,
-
ip_address: get_ip_address
-
}
-
end
-
-
def collect_form_data
-
data = {}
-
fields = @form[:form_fields][:fields] || []
-
-
fields.each do |field|
-
field_name = field.dig(:attributes, :name)
-
data[field_name] = params[field_name] if field_name && params.key?(field_name)
-
end
-
-
data
-
end
-
-
def detect_device
-
user_agent = request.user_agent.to_s.downcase
-
-
return 'mobile' if user_agent.include?('mobile')
-
return 'tablet' if user_agent.include?('tablet') || user_agent.include?('ipad')
-
'desktop'
-
end
-
-
def get_ip_address
-
plugin = FluentFormsPro.new
-
return nil if plugin.setting_enabled?('disable_ip_logging')
-
-
request.remote_ip
-
end
-
-
def store_entry_details(submission_id)
-
fields = @form[:form_fields][:fields] || []
-
-
fields.each do |field|
-
field_name = field.dig(:attributes, :name)
-
next unless field_name && params.key?(field_name)
-
-
ActiveRecord::Base.connection.execute(
-
"INSERT INTO ff_entry_details (submission_id, form_id, field_name, field_value, created_at, updated_at)
-
VALUES (?, ?, ?, ?, ?, ?)",
-
submission_id,
-
@form[:id],
-
field_name,
-
params[field_name].to_s,
-
Time.current,
-
Time.current
-
)
-
end
-
end
-
-
def has_file_uploads?
-
params[:_files].present? || params.values.any? { |v| v.is_a?(ActionDispatch::Http::UploadedFile) }
-
end
-
-
def handle_file_uploads(submission_id)
-
plugin = FluentFormsPro.new
-
upload_folder = plugin.get_setting('upload_folder', 'form-uploads')
-
max_size = plugin.get_setting('max_file_size', 10).to_i * 1024 * 1024 # Convert MB to bytes
-
allowed_types = plugin.get_setting('allowed_file_types', '').split(',').map(&:strip)
-
-
params.each do |key, value|
-
next unless value.is_a?(ActionDispatch::Http::UploadedFile)
-
-
# Validate file size
-
if value.size > max_size
-
next
-
end
-
-
# Validate file type
-
extension = File.extname(value.original_filename).delete('.').downcase
-
next unless allowed_types.include?(extension)
-
-
# Save file
-
upload_path = Rails.root.join('public', 'uploads', upload_folder, submission_id.to_s)
-
FileUtils.mkdir_p(upload_path)
-
-
filename = "#{Time.current.to_i}_#{value.original_filename}"
-
filepath = upload_path.join(filename)
-
-
File.open(filepath, 'wb') do |file|
-
file.write(value.read)
-
end
-
-
# Update entry detail with file path
-
file_url = "/uploads/#{upload_folder}/#{submission_id}/#{filename}"
-
ActiveRecord::Base.connection.execute(
-
"UPDATE ff_entry_details SET field_value = ?
-
WHERE submission_id = ? AND field_name = ?",
-
file_url,
-
submission_id,
-
key
-
)
-
end
-
rescue => e
-
Rails.logger.error "[Fluent Forms] File upload error: #{e.message}"
-
end
-
-
def process_payment(submission_id)
-
# Payment processing would be implemented here
-
# Integrate with Stripe, PayPal, etc.
-
{
-
success: true,
-
transaction_id: SecureRandom.hex(16)
-
}
-
end
-
-
def respond_with_success(message, submission_id = nil)
-
response = {
-
success: true,
-
message: message
-
}
-
response[:submission_id] = submission_id if submission_id
-
-
if request.format.json?
-
render json: response
-
else
-
flash[:success] = message
-
redirect_back fallback_location: root_path
-
end
-
end
-
-
def respond_with_error(message, status = 422)
-
response = {
-
success: false,
-
message: message
-
}
-
-
if request.format.json?
-
render json: response, status: status
-
else
-
flash[:error] = message
-
redirect_back fallback_location: root_path
-
end
-
end
-
end
-
-
-
class SubscribersController < ApplicationController
-
-
# POST /subscribe
-
def create
-
@subscriber = Subscriber.new(subscriber_params)
-
@subscriber.status = 'pending'
-
@subscriber.source = params[:source] || 'website'
-
@subscriber.ip_address = request.remote_ip
-
@subscriber.user_agent = request.user_agent
-
-
if @subscriber.save
-
respond_to do |format|
-
format.html do
-
redirect_back fallback_location: root_path, notice: 'Successfully subscribed! Please check your email to confirm.'
-
end
-
format.json do
-
render json: { success: true, message: 'Successfully subscribed' }, status: :created
-
end
-
end
-
else
-
respond_to do |format|
-
format.html do
-
redirect_back fallback_location: root_path, alert: @subscriber.errors.full_messages.join(', ')
-
end
-
format.json do
-
render json: { success: false, errors: @subscriber.errors.full_messages }, status: :unprocessable_entity
-
end
-
end
-
end
-
end
-
-
# GET /unsubscribe/:token
-
def unsubscribe
-
@subscriber = Subscriber.find_by(unsubscribe_token: params[:token])
-
-
unless @subscriber
-
redirect_to root_path, alert: 'Invalid unsubscribe link'
-
return
-
end
-
-
@subscriber.unsubscribe!
-
-
render :unsubscribe
-
end
-
-
# GET /confirm/:token
-
def confirm
-
@subscriber = Subscriber.find_by(unsubscribe_token: params[:token])
-
-
unless @subscriber
-
redirect_to root_path, alert: 'Invalid confirmation link'
-
return
-
end
-
-
if @subscriber.confirmed_status?
-
@already_confirmed = true
-
else
-
@subscriber.confirm!
-
end
-
-
render :confirm
-
end
-
-
private
-
-
def subscriber_params
-
params.require(:subscriber).permit(:email, :name)
-
end
-
end
-
-
-
-
-
-
-
-
-
class ThemeAssetsController < ApplicationController
-
skip_before_action :verify_authenticity_token
-
-
def show
-
theme_name = params[:theme]
-
asset_path_array = params[:path]
-
-
# Security: only allow alphanumeric, hyphens, underscores, and dots in theme name
-
unless theme_name.match?(/\A[a-zA-Z0-9_-]+\z/)
-
return head :not_found
-
end
-
-
# Construct the full path to the asset
-
full_path = Rails.root.join('app', 'themes', theme_name, 'assets', *asset_path_array)
-
-
# Security: ensure the path is within the theme directory
-
assets_dir = Rails.root.join('app', 'themes', theme_name, 'assets')
-
unless full_path.to_s.start_with?(assets_dir.to_s)
-
return head :forbidden
-
end
-
-
# Check if file exists
-
unless File.exist?(full_path) && File.file?(full_path)
-
Rails.logger.warn "Theme asset not found: #{full_path}"
-
return head :not_found
-
end
-
-
# Determine content type
-
extension = File.extname(full_path)[1..-1]
-
content_type = Mime::Type.lookup_by_extension(extension)
-
content_type ||= 'application/octet-stream'
-
-
# Send file with caching headers
-
expires_in 1.year, public: true
-
send_file full_path, type: content_type.to_s, disposition: 'inline'
-
end
-
end
-
class ThemesController < ApplicationController
-
# GET /themes/preview?theme=theme_name (public preview)
-
def preview
-
@theme_id = params[:id]
-
@theme = Theme.find(@theme_id)
-
@theme_name = @theme.name
-
@theme_config = load_theme_config(@theme_name)
-
-
# Ensure theme has a published version
-
@theme.ensure_published_version_exists!
-
published_version = @theme.published_version
-
-
# If still no published version, create one
-
unless published_version
-
@theme.ensure_published_version_exists!
-
published_version = @theme.published_version
-
end
-
-
if published_version
-
# Use FrontendRendererService for proper rendering
-
renderer = FrontendRendererService.new(published_version)
-
template_type = params[:template] || 'index'
-
-
begin
-
@preview_html = renderer.render_template(template_type, preview_context)
-
@assets = renderer.assets
-
rescue => e
-
Rails.logger.error "Theme preview rendering failed: #{e.message}"
-
@preview_html = "<div style='padding: 20px; color: red;'>Preview Error: #{e.message}</div>"
-
@assets = { css: '', js: '' }
-
end
-
else
-
@preview_html = "<div style='padding: 20px; color: red;'>No published version found for #{@theme_name}</div>"
-
@assets = { css: '', js: '' }
-
end
-
-
# Render homepage with preview theme
-
@featured_posts = Post.published.order(published_at: :desc).limit(3)
-
@recent_posts = Post.published.order(published_at: :desc).limit(6)
-
@categories = Term.for_taxonomy('category').root_terms.limit(5)
-
render 'preview', layout: false
-
end
-
-
# POST /themes/switch (if you want public theme switching)
-
def switch
-
theme_name = params[:theme]
-
-
# Only allow admins to switch themes
-
unless current_user&.administrator?
-
redirect_back fallback_location: root_path, alert: 'Permission denied'
-
return
-
end
-
-
if Railspress::ThemeLoader.activate_theme(theme_name)
-
redirect_back fallback_location: root_path, notice: "Theme switched to #{theme_name.titleize}"
-
else
-
redirect_back fallback_location: root_path, alert: "Failed to switch theme"
-
end
-
end
-
-
-
private
-
-
def load_theme_config(theme_name)
-
config_path = Rails.root.join('app', 'themes', theme_name, 'config.yml')
-
File.exist?(config_path) ? YAML.load_file(config_path) : {}
-
end
-
-
def preview_context
-
{
-
featured_posts: @featured_posts,
-
recent_posts: @recent_posts,
-
categories: @categories
-
}
-
end
-
end
-
-
-
-
# frozen_string_literal: true
-
-
class Users::ConfirmationsController < Devise::ConfirmationsController
-
# GET /resource/confirmation/new
-
# def new
-
# super
-
# end
-
-
# POST /resource/confirmation
-
# def create
-
# super
-
# end
-
-
# GET /resource/confirmation?confirmation_token=abcdef
-
# def show
-
# super
-
# end
-
-
# protected
-
-
# The path used after resending confirmation instructions.
-
# def after_resending_confirmation_instructions_path_for(resource_name)
-
# super(resource_name)
-
# end
-
-
# The path used after confirmation.
-
# def after_confirmation_path_for(resource_name, resource)
-
# super(resource_name, resource)
-
# end
-
end
-
# frozen_string_literal: true
-
-
class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
-
# You should configure your model like this:
-
# devise :omniauthable, omniauth_providers: [:twitter]
-
-
# You should also create an action method in this controller like this:
-
# def twitter
-
# end
-
-
# More info at:
-
# https://github.com/heartcombo/devise#omniauth
-
-
# GET|POST /resource/auth/twitter
-
# def passthru
-
# super
-
# end
-
-
# GET|POST /users/auth/twitter/callback
-
# def failure
-
# super
-
# end
-
-
# protected
-
-
# The path used when OmniAuth fails
-
# def after_omniauth_failure_path_for(scope)
-
# super(scope)
-
# end
-
end
-
# frozen_string_literal: true
-
-
class Users::PasswordsController < Devise::PasswordsController
-
# GET /resource/password/new
-
# def new
-
# super
-
# end
-
-
# POST /resource/password
-
# def create
-
# super
-
# end
-
-
# GET /resource/password/edit?reset_password_token=abcdef
-
# def edit
-
# super
-
# end
-
-
# PUT /resource/password
-
# def update
-
# super
-
# end
-
-
# protected
-
-
# def after_resetting_password_path_for(resource)
-
# super(resource)
-
# end
-
-
# The path used after sending reset password instructions
-
# def after_sending_reset_password_instructions_path_for(resource_name)
-
# super(resource_name)
-
# end
-
end
-
# frozen_string_literal: true
-
-
class Users::RegistrationsController < Devise::RegistrationsController
-
# before_action :configure_sign_up_params, only: [:create]
-
# before_action :configure_account_update_params, only: [:update]
-
-
# GET /resource/sign_up
-
# def new
-
# super
-
# end
-
-
# POST /resource
-
# def create
-
# super
-
# end
-
-
# GET /resource/edit
-
# def edit
-
# super
-
# end
-
-
# PUT /resource
-
# def update
-
# super
-
# end
-
-
# DELETE /resource
-
# def destroy
-
# super
-
# end
-
-
# GET /resource/cancel
-
# Forces the session data which is usually expired after sign
-
# in to be expired now. This is useful if the user wants to
-
# cancel oauth signing in/up in the middle of the process,
-
# removing all OAuth session data.
-
# def cancel
-
# super
-
# end
-
-
# protected
-
-
# If you have extra params to permit, append them to the sanitizer.
-
# def configure_sign_up_params
-
# devise_parameter_sanitizer.permit(:sign_up, keys: [:attribute])
-
# end
-
-
# If you have extra params to permit, append them to the sanitizer.
-
# def configure_account_update_params
-
# devise_parameter_sanitizer.permit(:account_update, keys: [:attribute])
-
# end
-
-
# The path used after sign up.
-
# def after_sign_up_path_for(resource)
-
# super(resource)
-
# end
-
-
# The path used after sign up for inactive accounts.
-
# def after_inactive_sign_up_path_for(resource)
-
# super(resource)
-
# end
-
end
-
# frozen_string_literal: true
-
-
class Users::SessionsController < Devise::SessionsController
-
# before_action :configure_sign_in_params, only: [:create]
-
-
# GET /resource/sign_in
-
# def new
-
# super
-
# end
-
-
# POST /resource/sign_in
-
# def create
-
# super
-
# end
-
-
# DELETE /resource/sign_out
-
# def destroy
-
# super
-
# end
-
-
# protected
-
-
# If you have extra params to permit, append them to the sanitizer.
-
# def configure_sign_in_params
-
# devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute])
-
# end
-
end
-
# frozen_string_literal: true
-
-
class Users::UnlocksController < Devise::UnlocksController
-
# GET /resource/unlock/new
-
# def new
-
# super
-
# end
-
-
# POST /resource/unlock
-
# def create
-
# super
-
# end
-
-
# GET /resource/unlock?unlock_token=abcdef
-
# def show
-
# super
-
# end
-
-
# protected
-
-
# The path used after sending unlock password instructions
-
# def after_sending_unlock_instructions_path_for(resource)
-
# super(resource)
-
# end
-
-
# The path used after unlocking the resource
-
# def after_unlock_path_for(resource)
-
# super(resource)
-
# end
-
end
-
# frozen_string_literal: true
-
-
module Mutations
-
class BaseMutation < GraphQL::Schema::RelayClassicMutation
-
argument_class Types::BaseArgument
-
field_class Types::BaseField
-
input_object_class Types::BaseInputObject
-
object_class Types::BaseObject
-
end
-
end
-
module Mutations
-
module GdprMutations
-
# Request personal data export
-
class RequestDataExport < Mutations::BaseMutation
-
description "Request export of personal data (GDPR Article 20 - Right to Data Portability)"
-
-
argument :user_id, ID, required: true, description: "User ID to export data for"
-
-
field :success, Boolean, null: false, description: "Whether the request was successful"
-
field :message, String, null: false, description: "Response message"
-
field :export_request, Types::GdprExportRequestType, null: true, description: "Created export request"
-
field :errors, [String], null: true, description: "List of errors"
-
-
def resolve(user_id:)
-
user = User.find(user_id)
-
-
# Check permissions
-
unless context[:current_user]&.administrator? || context[:current_user] == user
-
return {
-
success: false,
-
message: 'Access denied',
-
export_request: nil,
-
errors: ['Insufficient permissions']
-
}
-
end
-
-
begin
-
export_request = GdprService.create_export_request(user, context[:current_user])
-
-
{
-
success: true,
-
message: 'Personal data export request created successfully',
-
export_request: export_request,
-
errors: nil
-
}
-
rescue => e
-
{
-
success: false,
-
message: 'Failed to create export request',
-
export_request: nil,
-
errors: [e.message]
-
}
-
end
-
end
-
end
-
-
# Request personal data erasure
-
class RequestDataErasure < Mutations::BaseMutation
-
description "Request erasure of personal data (GDPR Article 17 - Right to Erasure)"
-
-
argument :user_id, ID, required: true, description: "User ID to erase data for"
-
argument :reason, String, required: false, description: "Reason for data erasure"
-
-
field :success, Boolean, null: false, description: "Whether the request was successful"
-
field :message, String, null: false, description: "Response message"
-
field :erasure_request, Types::GdprErasureRequestType, null: true, description: "Created erasure request"
-
field :errors, [String], null: true, description: "List of errors"
-
-
def resolve(user_id:, reason: nil)
-
user = User.find(user_id)
-
-
# Check permissions
-
unless context[:current_user]&.administrator? || context[:current_user] == user
-
return {
-
success: false,
-
message: 'Access denied',
-
erasure_request: nil,
-
errors: ['Insufficient permissions']
-
}
-
end
-
-
# Prevent admins from being erased
-
if user.administrator?
-
return {
-
success: false,
-
message: 'Cannot erase data for administrator accounts',
-
erasure_request: nil,
-
errors: ['Administrator accounts cannot be erased']
-
}
-
end
-
-
begin
-
erasure_request = GdprService.create_erasure_request(user, context[:current_user], reason)
-
-
{
-
success: true,
-
message: 'Data erasure request created successfully',
-
erasure_request: erasure_request,
-
errors: nil
-
}
-
rescue => e
-
{
-
success: false,
-
message: 'Failed to create erasure request',
-
erasure_request: nil,
-
errors: [e.message]
-
}
-
end
-
end
-
end
-
-
# Confirm data erasure request
-
class ConfirmDataErasure < Mutations::BaseMutation
-
description "Confirm a data erasure request"
-
-
argument :token, String, required: true, description: "Erasure request token"
-
-
field :success, Boolean, null: false, description: "Whether the confirmation was successful"
-
field :message, String, null: false, description: "Response message"
-
field :erasure_request, Types::GdprErasureRequestType, null: true, description: "Confirmed erasure request"
-
field :errors, [String], null: true, description: "List of errors"
-
-
def resolve(token:)
-
erasure_request = PersonalDataErasureRequest.find_by(token: token)
-
-
unless erasure_request
-
return {
-
success: false,
-
message: 'Erasure request not found',
-
erasure_request: nil,
-
errors: ['Invalid token']
-
}
-
end
-
-
if erasure_request.status != 'pending_confirmation'
-
return {
-
success: false,
-
message: 'This request has already been processed',
-
erasure_request: erasure_request,
-
errors: ['Request already processed']
-
}
-
end
-
-
begin
-
GdprService.confirm_erasure_request(erasure_request, context[:current_user])
-
-
{
-
success: true,
-
message: 'Data erasure confirmed and queued for processing',
-
erasure_request: erasure_request,
-
errors: nil
-
}
-
rescue => e
-
{
-
success: false,
-
message: 'Failed to confirm erasure request',
-
erasure_request: nil,
-
errors: [e.message]
-
}
-
end
-
end
-
end
-
-
# Record user consent
-
class RecordConsent < Mutations::BaseMutation
-
description "Record user consent (GDPR Article 7)"
-
-
argument :user_id, ID, required: true, description: "User ID"
-
argument :consent_type, String, required: true, description: "Type of consent"
-
argument :consent_data, GraphQL::Types::JSON, required: true, description: "Consent data"
-
-
field :success, Boolean, null: false, description: "Whether the consent was recorded successfully"
-
field :message, String, null: false, description: "Response message"
-
field :consent_record, Types::GdprConsentRecordType, null: true, description: "Created consent record"
-
field :errors, [String], null: true, description: "List of errors"
-
-
def resolve(user_id:, consent_type:, consent_data:)
-
user = User.find(user_id)
-
-
# Check permissions
-
unless context[:current_user]&.administrator? || context[:current_user] == user
-
return {
-
success: false,
-
message: 'Access denied',
-
consent_record: nil,
-
errors: ['Insufficient permissions']
-
}
-
end
-
-
begin
-
consent_record = GdprService.record_user_consent(user, consent_type, consent_data)
-
-
{
-
success: true,
-
message: 'Consent recorded successfully',
-
consent_record: consent_record,
-
errors: nil
-
}
-
rescue => e
-
{
-
success: false,
-
message: 'Failed to record consent',
-
consent_record: nil,
-
errors: [e.message]
-
}
-
end
-
end
-
end
-
-
# Withdraw user consent
-
class WithdrawConsent < Mutations::BaseMutation
-
description "Withdraw user consent"
-
-
argument :user_id, ID, required: true, description: "User ID"
-
argument :consent_type, String, required: true, description: "Type of consent to withdraw"
-
-
field :success, Boolean, null: false, description: "Whether the consent was withdrawn successfully"
-
field :message, String, null: false, description: "Response message"
-
field :consent_record, Types::GdprConsentRecordType, null: true, description: "Updated consent record"
-
field :errors, [String], null: true, description: "List of errors"
-
-
def resolve(user_id:, consent_type:)
-
user = User.find(user_id)
-
-
# Check permissions
-
unless context[:current_user]&.administrator? || context[:current_user] == user
-
return {
-
success: false,
-
message: 'Access denied',
-
consent_record: nil,
-
errors: ['Insufficient permissions']
-
}
-
end
-
-
begin
-
consent_record = GdprService.withdraw_user_consent(user, consent_type)
-
-
{
-
success: true,
-
message: 'Consent withdrawn successfully',
-
consent_record: consent_record,
-
errors: nil
-
}
-
rescue => e
-
{
-
success: false,
-
message: 'Failed to withdraw consent',
-
consent_record: nil,
-
errors: [e.message]
-
}
-
end
-
end
-
end
-
end
-
end
-
module Mutations
-
module MetaFields
-
class CreateMetaField < BaseMutation
-
description "Create a new meta field for a model"
-
-
argument :metable_type, String, required: true, description: "Type of the parent object (Post, Page, User, AiAgent)"
-
argument :metable_id, ID, required: true, description: "ID of the parent object"
-
argument :key, String, required: true, description: "The key/name of the meta field"
-
argument :value, String, required: false, description: "The value of the meta field"
-
argument :immutable, Boolean, required: false, default_value: false, description: "Whether this meta field can be modified"
-
-
field :meta_field, Types::MetaFieldType, null: true, description: "The created meta field"
-
field :errors, [String], null: false, description: "Any errors that occurred"
-
-
def resolve(metable_type:, metable_id:, key:, value: nil, immutable: false)
-
# Validate metable_type
-
unless %w[Post Page User AiAgent].include?(metable_type.classify)
-
return {
-
meta_field: nil,
-
errors: ["Invalid metable type. Must be one of: Post, Page, User, AiAgent"]
-
}
-
end
-
-
begin
-
# Find the parent object
-
metable = metable_type.classify.constantize.find(metable_id)
-
-
# Create the meta field
-
meta_field = metable.meta_fields.build(
-
key: key,
-
value: value,
-
immutable: immutable
-
)
-
-
if meta_field.save
-
{
-
meta_field: meta_field,
-
errors: []
-
}
-
else
-
{
-
meta_field: nil,
-
errors: meta_field.errors.full_messages
-
}
-
end
-
rescue ActiveRecord::RecordNotFound
-
{
-
meta_field: nil,
-
errors: ["#{metable_type} not found"]
-
}
-
rescue => e
-
{
-
meta_field: nil,
-
errors: [e.message]
-
}
-
end
-
end
-
end
-
end
-
end
-
-
-
-
-
# frozen_string_literal: true
-
-
class RailspressSchema < GraphQL::Schema
-
mutation(Types::MutationType)
-
query(Types::QueryType)
-
-
# For batch-loading (see https://graphql-ruby.org/dataloader/overview.html)
-
use GraphQL::Dataloader
-
-
# GraphQL-Ruby calls this when something goes wrong while running a query:
-
def self.type_error(err, context)
-
# if err.is_a?(GraphQL::InvalidNullError)
-
# # report to your bug tracker here
-
# return nil
-
# end
-
super
-
end
-
-
# Union and Interface Resolution
-
def self.resolve_type(abstract_type, obj, ctx)
-
# TODO: Implement this method
-
# to return the correct GraphQL object type for `obj`
-
raise(GraphQL::RequiredImplementationMissingError)
-
end
-
-
# Limit the size of incoming queries:
-
max_query_string_tokens(5000)
-
-
# Stop validating when it encounters this many errors:
-
validate_max_errors(100)
-
-
# Relay-style Object Identification:
-
-
# Return a string UUID for `object`
-
def self.id_from_object(object, type_definition, query_ctx)
-
# For example, use Rails' GlobalID library (https://github.com/rails/globalid):
-
object.to_gid_param
-
end
-
-
# Given a string UUID, find the object
-
def self.object_from_id(global_id, query_ctx)
-
# For example, use Rails' GlobalID library (https://github.com/rails/globalid):
-
GlobalID.find(global_id)
-
end
-
end
-
# frozen_string_literal: true
-
-
module Resolvers
-
class BaseResolver < GraphQL::Schema::Resolver
-
end
-
end
-
module Resolvers
-
class ChannelResolver < Resolvers::BaseResolver
-
description "Find a single channel"
-
-
argument :id, ID, required: false, description: "Find channel by ID"
-
argument :slug, String, required: false, description: "Find channel by slug"
-
-
type Types::ChannelType, null: true
-
-
def resolve(id: nil, slug: nil)
-
if id.present?
-
Channel.find(id)
-
elsif slug.present?
-
Channel.find_by(slug: slug)
-
else
-
raise GraphQL::ExecutionError, "Either id or slug must be provided"
-
end
-
end
-
end
-
end
-
module Resolvers
-
class ChannelsResolver < Resolvers::BaseResolver
-
description "Find channels"
-
-
argument :slug, String, required: false, description: "Find channel by slug"
-
argument :domain, String, required: false, description: "Find channel by domain"
-
argument :enabled, Boolean, required: false, description: "Filter by enabled status"
-
argument :device_type, String, required: false, description: "Filter by device type"
-
-
type [Types::ChannelType], null: true
-
-
def resolve(slug: nil, domain: nil, enabled: nil, device_type: nil)
-
channels = Channel.all
-
-
channels = channels.where(slug: slug) if slug.present?
-
channels = channels.where(domain: domain) if domain.present?
-
channels = channels.where(enabled: enabled) if enabled != nil
-
-
if device_type.present?
-
channels = channels.where("metadata->>'device_type' = ?", device_type)
-
end
-
-
channels.order(:name)
-
end
-
end
-
end
-
module Resolvers
-
class ImageOptimizationResolver < Resolvers::BaseResolver
-
description "Image optimization queries and mutations"
-
-
# Query: Get optimization analytics
-
field :image_optimization_analytics, Types::ImageOptimizationStatsType, null: false do
-
description "Get image optimization analytics"
-
end
-
-
# Query: Get optimization logs
-
field :image_optimization_logs, [Types::ImageOptimizationLogType], null: true do
-
description "Get image optimization logs"
-
argument :limit, Integer, required: false, default_value: 50
-
argument :status, String, required: false
-
argument :compression_level, String, required: false
-
argument :optimization_type, String, required: false
-
end
-
-
# Query: Get failed optimizations
-
field :failed_image_optimizations, [Types::ImageOptimizationLogType], null: true do
-
description "Get failed image optimizations"
-
argument :limit, Integer, required: false, default_value: 20
-
end
-
-
# Query: Get top savings
-
field :top_image_savings, [Types::ImageOptimizationLogType], null: true do
-
description "Get top image optimization savings"
-
argument :limit, Integer, required: false, default_value: 10
-
end
-
-
# Query: Get compression levels
-
field :compression_levels, [Types::CompressionLevelType], null: true do
-
description "Get available compression levels"
-
end
-
-
# Query: Get optimization report
-
field :image_optimization_report, Types::ImageOptimizationReportType, null: true do
-
description "Get image optimization report"
-
argument :start_date, GraphQL::Types::ISO8601Date, required: false
-
argument :end_date, GraphQL::Types::ISO8601Date, required: false
-
end
-
-
# Mutation: Bulk optimize images
-
field :bulk_optimize_images, GraphQL::Types::Boolean, null: false do
-
description "Start bulk optimization of images"
-
end
-
-
# Mutation: Regenerate variants
-
field :regenerate_image_variants, GraphQL::Types::Boolean, null: false do
-
description "Regenerate image variants"
-
argument :medium_id, ID, required: true
-
end
-
-
# Mutation: Clear optimization logs
-
field :clear_optimization_logs, GraphQL::Types::Boolean, null: false do
-
description "Clear all optimization logs"
-
argument :confirm, Boolean, required: true
-
end
-
-
def image_optimization_analytics
-
{
-
total_optimizations: ImageOptimizationLog.count,
-
successful_optimizations: ImageOptimizationLog.successful.count,
-
failed_optimizations: ImageOptimizationLog.failed.count,
-
skipped_optimizations: ImageOptimizationLog.skipped.count,
-
total_bytes_saved: ImageOptimizationLog.total_bytes_saved || 0,
-
total_size_saved_mb: ((ImageOptimizationLog.total_bytes_saved || 0) / 1024.0 / 1024.0).round(2),
-
average_size_reduction: ImageOptimizationLog.average_size_reduction&.round(2),
-
average_processing_time: ImageOptimizationLog.average_processing_time&.round(3),
-
today_optimizations: ImageOptimizationLog.today.count,
-
this_week_optimizations: ImageOptimizationLog.this_week.count,
-
this_month_optimizations: ImageOptimizationLog.this_month.count
-
}
-
end
-
-
def image_optimization_logs(limit:, status:, compression_level:, optimization_type:)
-
logs = ImageOptimizationLog.includes(:medium, :upload, :user)
-
-
logs = logs.where(status: status) if status.present?
-
logs = logs.where(compression_level: compression_level) if compression_level.present?
-
logs = logs.where(optimization_type: optimization_type) if optimization_type.present?
-
-
logs.recent.limit(limit)
-
end
-
-
def failed_image_optimizations(limit:)
-
ImageOptimizationLog.failed_optimizations
-
.includes(:medium, :upload, :user)
-
.limit(limit)
-
end
-
-
def top_image_savings(limit:)
-
ImageOptimizationLog.top_savings(limit).includes(:medium, :upload, :user)
-
end
-
-
def compression_levels
-
ImageOptimizationService.available_compression_levels.map do |key, config|
-
{
-
name: config[:name],
-
description: config[:description],
-
quality: config[:quality],
-
compression_level: config[:compression_level],
-
lossy: config[:lossy],
-
expected_savings: config[:expected_savings],
-
recommended_for: config[:recommended_for]
-
}
-
end
-
end
-
-
def image_optimization_report(start_date:, end_date:)
-
start_date ||= 30.days.ago.to_date
-
end_date ||= Date.current
-
-
report = ImageOptimizationLog.generate_report(start_date, end_date)
-
-
{
-
total_optimizations: report[:total_optimizations],
-
successful_optimizations: report[:successful_optimizations],
-
failed_optimizations: report[:failed_optimizations],
-
skipped_optimizations: report[:skipped_optimizations],
-
total_bytes_saved: report[:total_bytes_saved],
-
total_size_saved_mb: report[:total_size_saved_mb],
-
average_size_reduction: report[:average_size_reduction],
-
average_processing_time: report[:average_processing_time],
-
compression_level_breakdown: report[:compression_level_breakdown],
-
optimization_type_breakdown: report[:optimization_type_breakdown],
-
daily_optimizations: report[:daily_optimizations],
-
top_users: report[:top_users],
-
top_tenants: report[:top_tenants]
-
}
-
end
-
-
def bulk_optimize_images
-
# Get all unoptimized images
-
unoptimized_uploads = Upload.joins(:media)
-
.where(media: { id: Medium.where.not(id: ImageOptimizationLog.select(:medium_id)) })
-
.where.not(file: nil)
-
-
return true if unoptimized_uploads.empty?
-
-
# Queue optimization jobs
-
unoptimized_uploads.limit(100).each do |upload|
-
medium = upload.media.first
-
if medium
-
OptimizeImageJob.perform_later(
-
medium_id: medium.id,
-
optimization_type: 'bulk',
-
request_context: {
-
user_agent: context[:request]&.user_agent,
-
ip_address: context[:request]&.remote_ip
-
}
-
)
-
end
-
end
-
-
true
-
end
-
-
def regenerate_image_variants(medium_id:)
-
medium = Medium.find(medium_id)
-
OptimizeImageJob.perform_later(
-
medium_id: medium.id,
-
optimization_type: 'regenerate',
-
request_context: {
-
user_agent: context[:request]&.user_agent,
-
ip_address: context[:request]&.remote_ip
-
}
-
)
-
true
-
end
-
-
def clear_optimization_logs(confirm:)
-
return false unless confirm
-
-
ImageOptimizationLog.delete_all
-
true
-
end
-
end
-
end
-
module Resolvers
-
class MediaResolver < Resolvers::BaseResolver
-
description "Find media files with channel filtering"
-
-
argument :channel, String, required: false, description: "Filter media by channel slug"
-
argument :file_type, String, required: false, description: "Filter by file type"
-
argument :search, String, required: false, description: "Search in title and description"
-
argument :limit, Integer, required: false, description: "Limit number of results"
-
argument :offset, Integer, required: false, description: "Offset for pagination"
-
-
type [Types::MediumType], null: true
-
-
def resolve(channel: nil, file_type: nil, search: nil, limit: nil, offset: nil)
-
media = Medium.all
-
-
# Apply filters
-
media = media.where(file_type: file_type) if file_type.present?
-
media = media.where("title ILIKE ? OR description ILIKE ?", "%#{search}%", "%#{search}%") if search.present?
-
-
# Channel filtering
-
if channel.present?
-
channel_obj = Channel.find_by(slug: channel)
-
if channel_obj
-
media = media.left_joins(:channels)
-
.where('channels.id = ? OR channels.id IS NULL', channel_obj.id)
-
-
# Apply channel exclusions
-
excluded_media_ids = channel_obj.channel_overrides
-
.exclusions
-
.enabled
-
.where(resource_type: 'Medium')
-
.pluck(:resource_id)
-
media = media.where.not(id: excluded_media_ids) if excluded_media_ids.any?
-
-
# Set channel context for serialization
-
context[:current_channel] = channel_obj
-
end
-
end
-
-
# Pagination
-
media = media.limit(limit) if limit.present?
-
media = media.offset(offset) if offset.present?
-
-
media.order(created_at: :desc)
-
end
-
end
-
end
-
module Resolvers
-
class PagesResolver < Resolvers::BaseResolver
-
description "Find pages with channel filtering"
-
-
argument :channel, String, required: false, description: "Filter pages by channel slug"
-
argument :status, String, required: false, description: "Filter by page status"
-
argument :parent_id, Integer, required: false, description: "Filter by parent page ID"
-
argument :search, String, required: false, description: "Search in title and content"
-
argument :limit, Integer, required: false, description: "Limit number of results"
-
argument :offset, Integer, required: false, description: "Offset for pagination"
-
-
type [Types::PageType], null: true
-
-
def resolve(channel: nil, status: nil, parent_id: nil, search: nil, limit: nil, offset: nil)
-
pages = Page.all
-
-
# Apply filters
-
pages = pages.where(status: status) if status.present?
-
pages = pages.where(parent_id: parent_id) if parent_id.present?
-
pages = pages.where("title ILIKE ? OR content ILIKE ?", "%#{search}%", "%#{search}%") if search.present?
-
-
# Channel filtering
-
if channel.present?
-
channel_obj = Channel.find_by(slug: channel)
-
if channel_obj
-
pages = pages.left_joins(:channels)
-
.where('channels.id = ? OR channels.id IS NULL', channel_obj.id)
-
-
# Apply channel exclusions
-
excluded_page_ids = channel_obj.channel_overrides
-
.exclusions
-
.enabled
-
.where(resource_type: 'Page')
-
.pluck(:resource_id)
-
pages = pages.where.not(id: excluded_page_ids) if excluded_page_ids.any?
-
-
# Set channel context for serialization
-
context[:current_channel] = channel_obj
-
end
-
end
-
-
# Only published for non-authenticated users
-
unless context[:current_user]&.can_edit_others_posts?
-
pages = pages.published
-
end
-
-
# Pagination
-
pages = pages.limit(limit) if limit.present?
-
pages = pages.offset(offset) if offset.present?
-
-
pages.order(:order, :title)
-
end
-
end
-
end
-
module Resolvers
-
class PostsResolver < Resolvers::BaseResolver
-
description "Find posts with channel filtering"
-
-
argument :channel, String, required: false, description: "Filter posts by channel slug"
-
argument :status, String, required: false, description: "Filter by post status"
-
argument :category, String, required: false, description: "Filter by category slug"
-
argument :tag, String, required: false, description: "Filter by tag slug"
-
argument :search, String, required: false, description: "Search in title and content"
-
argument :limit, Integer, required: false, description: "Limit number of results"
-
argument :offset, Integer, required: false, description: "Offset for pagination"
-
-
type [Types::PostType], null: true
-
-
def resolve(channel: nil, status: nil, category: nil, tag: nil, search: nil, limit: nil, offset: nil)
-
posts = Post.all
-
-
# Apply filters
-
posts = posts.where(status: status) if status.present?
-
posts = posts.by_category(category) if category.present?
-
posts = posts.by_tag(tag) if tag.present?
-
posts = posts.search(search) if search.present?
-
-
# Channel filtering
-
if channel.present?
-
channel_obj = Channel.find_by(slug: channel)
-
if channel_obj
-
posts = posts.left_joins(:channels)
-
.where('channels.id = ? OR channels.id IS NULL', channel_obj.id)
-
-
# Apply channel exclusions
-
excluded_post_ids = channel_obj.channel_overrides
-
.exclusions
-
.enabled
-
.where(resource_type: 'Post')
-
.pluck(:resource_id)
-
posts = posts.where.not(id: excluded_post_ids) if excluded_post_ids.any?
-
-
# Set channel context for serialization
-
context[:current_channel] = channel_obj
-
end
-
end
-
-
# Only published for non-authenticated users
-
unless context[:current_user]&.can_edit_others_posts?
-
posts = posts.published
-
end
-
-
# Pagination
-
posts = posts.limit(limit) if limit.present?
-
posts = posts.offset(offset) if offset.present?
-
-
posts.order(created_at: :desc)
-
end
-
end
-
end
-
module Types
-
class AiAgentType < Types::BaseObject
-
description "An AI agent for automated tasks"
-
-
field :id, ID, null: false
-
field :name, String, null: false
-
field :description, String, null: true
-
field :prompt, String, null: false
-
field :guidelines, String, null: true
-
field :tasks, String, null: true
-
field :agent_type, String, null: false
-
field :active, Boolean, null: false
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# AI Provider
-
field :ai_provider, Types::BaseObject, null: true do
-
description "The AI provider this agent uses"
-
end
-
-
# Usage statistics
-
field :usage_count, Integer, null: false, description: "Number of times this agent has been used"
-
field :last_used_at, GraphQL::Types::ISO8601DateTime, null: true, description: "When this agent was last used"
-
-
# Meta Fields
-
field :meta_fields, [Types::MetaFieldType], null: true, description: "Custom meta fields for this AI agent" do
-
argument :key, String, required: false, description: "Filter by specific meta field key"
-
argument :immutable, Boolean, required: false, description: "Filter by immutable status"
-
end
-
-
field :meta_field, Types::MetaFieldType, null: true, description: "Get a specific meta field by key" do
-
argument :key, String, required: true, description: "The key of the meta field to retrieve"
-
end
-
-
field :all_meta, GraphQL::Types::JSON, null: true, description: "All meta fields as a key-value hash"
-
-
def ai_provider
-
object.ai_provider
-
end
-
-
def usage_count
-
object.ai_usages.count
-
end
-
-
def last_used_at
-
object.ai_usages.order(created_at: :desc).first&.created_at
-
end
-
-
def meta_fields(key: nil, immutable: nil)
-
meta_fields = object.meta_fields
-
meta_fields = meta_fields.by_key(key) if key.present?
-
meta_fields = meta_fields.immutable if immutable == true
-
meta_fields = meta_fields.mutable if immutable == false
-
meta_fields
-
end
-
-
def meta_field(key:)
-
object.meta_fields.find_by(key: key)
-
end
-
-
def all_meta
-
object.all_meta
-
end
-
end
-
end
-
-
-
-
-
# frozen_string_literal: true
-
-
module Types
-
class AnalyticsType < Types::BaseObject
-
description "Analytics data for posts and pages"
-
-
field :total_views, Integer, null: false, description: "Total number of page views"
-
field :unique_readers, Integer, null: false, description: "Number of unique readers"
-
field :medium_readers, Integer, null: false, description: "Number of Medium-like readers (30+ seconds)"
-
field :reader_conversion_rate, Float, null: false, description: "Percentage of visitors who become readers"
-
field :returning_readers, Integer, null: false, description: "Number of returning readers"
-
field :avg_reading_time, Integer, null: false, description: "Average reading time in seconds"
-
field :avg_engagement_score, Float, null: false, description: "Average engagement score (0-100)"
-
field :avg_scroll_depth, Integer, null: false, description: "Average scroll depth percentage"
-
field :avg_completion_rate, Float, null: false, description: "Average completion rate percentage"
-
field :avg_time_on_page, Integer, null: false, description: "Average time on page in seconds"
-
field :readers_who_scrolled_to_bottom, Integer, null: false, description: "Readers who scrolled to bottom"
-
field :readers_who_spent_time, Integer, null: false, description: "Readers who spent significant time"
-
field :readers_with_exit_intent, Integer, null: false, description: "Readers who showed exit intent"
-
field :readers_by_country, [Types::CountryAnalyticsType], null: false, description: "Reader demographics by country"
-
field :readers_by_device, [Types::DeviceAnalyticsType], null: false, description: "Reader demographics by device"
-
field :readers_by_browser, [Types::BrowserAnalyticsType], null: false, description: "Reader demographics by browser"
-
field :traffic_sources, [Types::TrafficSourceType], null: false, description: "Traffic sources analysis"
-
field :direct_traffic, Integer, null: false, description: "Direct traffic count"
-
field :organic_traffic, Integer, null: false, description: "Organic search traffic count"
-
field :social_traffic, Integer, null: false, description: "Social media traffic count"
-
end
-
-
class CountryAnalyticsType < Types::BaseObject
-
description "Analytics data by country"
-
-
field :country_code, String, null: false, description: "Country code (ISO 3166-1 alpha-2)"
-
field :country_name, String, null: false, description: "Full country name"
-
field :count, Integer, null: false, description: "Number of readers from this country"
-
field :percentage, Float, null: false, description: "Percentage of total readers"
-
end
-
-
class DeviceAnalyticsType < Types::BaseObject
-
description "Analytics data by device type"
-
-
field :device, String, null: false, description: "Device type"
-
field :count, Integer, null: false, description: "Number of readers using this device"
-
field :percentage, Float, null: false, description: "Percentage of total readers"
-
end
-
-
class BrowserAnalyticsType < Types::BaseObject
-
description "Analytics data by browser"
-
-
field :browser, String, null: false, description: "Browser name"
-
field :count, Integer, null: false, description: "Number of readers using this browser"
-
field :percentage, Float, null: false, description: "Percentage of total readers"
-
end
-
-
class TrafficSourceType < Types::BaseObject
-
description "Traffic source information"
-
-
field :referrer, String, null: false, description: "Referrer URL or source name"
-
field :count, Integer, null: false, description: "Number of visits from this source"
-
field :percentage, Float, null: false, description: "Percentage of total traffic"
-
end
-
-
class RealtimeAnalyticsType < Types::BaseObject
-
description "Real-time analytics data"
-
-
field :active_users, Integer, null: false, description: "Currently active users"
-
field :current_pageviews, Integer, null: false, description: "Current pageviews count"
-
field :top_pages_now, [Types::PageAnalyticsType], null: false, description: "Top pages being viewed now"
-
field :active_countries, [Types::CountryAnalyticsType], null: false, description: "Active countries"
-
field :timestamp, GraphQL::Types::ISO8601DateTime, null: false, description: "Data timestamp"
-
end
-
-
class PageAnalyticsType < Types::BaseObject
-
description "Page analytics summary"
-
-
field :path, String, null: false, description: "Page path"
-
field :title, String, null: true, description: "Page title"
-
field :views, Integer, null: false, description: "Number of views"
-
end
-
-
class AnalyticsOverviewType < Types::BaseObject
-
description "Complete analytics overview"
-
-
field :total_pageviews, Integer, null: false, description: "Total pageviews for period"
-
field :unique_visitors, Integer, null: false, description: "Unique visitors for period"
-
field :top_posts, [Types::ContentAnalyticsType], null: false, description: "Top performing posts"
-
field :top_pages, [Types::ContentAnalyticsType], null: false, description: "Top performing pages"
-
field :traffic_sources, [Types::TrafficSourceType], null: false, description: "Traffic sources"
-
field :audience_insights, Types::AudienceInsightsType, null: false, description: "Audience insights"
-
field :period, String, null: false, description: "Analytics period"
-
field :generated_at, GraphQL::Types::ISO8601DateTime, null: false, description: "Data generation timestamp"
-
end
-
-
class ContentAnalyticsType < Types::BaseObject
-
description "Content analytics summary"
-
-
field :id, ID, null: false, description: "Content ID"
-
field :title, String, null: false, description: "Content title"
-
field :slug, String, null: false, description: "Content slug"
-
field :views, Integer, null: false, description: "Number of views"
-
field :unique_readers, Integer, null: false, description: "Number of unique readers"
-
field :medium_readers, Integer, null: false, description: "Number of Medium-like readers"
-
field :avg_engagement_score, Float, null: false, description: "Average engagement score"
-
field :avg_reading_time, Integer, null: false, description: "Average reading time"
-
field :published_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Publication date"
-
end
-
-
class AudienceInsightsType < Types::BaseObject
-
description "Audience insights data"
-
-
field :top_countries, [Types::CountryAnalyticsType], null: false, description: "Top countries by traffic"
-
field :browsers, [Types::BrowserAnalyticsType], null: false, description: "Browser distribution"
-
field :devices, [Types::DeviceAnalyticsType], null: false, description: "Device distribution"
-
field :operating_systems, [Types::OperatingSystemAnalyticsType], null: false, description: "OS distribution"
-
field :avg_session_duration, Integer, null: false, description: "Average session duration in seconds"
-
field :bounce_rate, Float, null: false, description: "Bounce rate percentage"
-
field :pages_per_session, Float, null: false, description: "Average pages per session"
-
end
-
-
class OperatingSystemAnalyticsType < Types::BaseObject
-
description "Analytics data by operating system"
-
-
field :os, String, null: false, description: "Operating system name"
-
field :count, Integer, null: false, description: "Number of readers using this OS"
-
field :percentage, Float, null: false, description: "Percentage of total readers"
-
end
-
end
-
# frozen_string_literal: true
-
-
module Types
-
class BaseArgument < GraphQL::Schema::Argument
-
end
-
end
-
# frozen_string_literal: true
-
-
module Types
-
class BaseConnection < Types::BaseObject
-
# add `nodes` and `pageInfo` fields, as well as `edge_type(...)` and `node_nullable(...)` overrides
-
include GraphQL::Types::Relay::ConnectionBehaviors
-
end
-
end
-
# frozen_string_literal: true
-
-
module Types
-
class BaseEdge < Types::BaseObject
-
# add `node` and `cursor` fields, as well as `node_type(...)` override
-
include GraphQL::Types::Relay::EdgeBehaviors
-
end
-
end
-
# frozen_string_literal: true
-
-
module Types
-
class BaseEnum < GraphQL::Schema::Enum
-
end
-
end
-
# frozen_string_literal: true
-
-
module Types
-
class BaseField < GraphQL::Schema::Field
-
argument_class Types::BaseArgument
-
end
-
end
-
# frozen_string_literal: true
-
-
module Types
-
class BaseInputObject < GraphQL::Schema::InputObject
-
argument_class Types::BaseArgument
-
end
-
end
-
# frozen_string_literal: true
-
-
module Types
-
module BaseInterface
-
include GraphQL::Schema::Interface
-
edge_type_class(Types::BaseEdge)
-
connection_type_class(Types::BaseConnection)
-
-
field_class Types::BaseField
-
end
-
end
-
# frozen_string_literal: true
-
-
module Types
-
class BaseObject < GraphQL::Schema::Object
-
edge_type_class(Types::BaseEdge)
-
connection_type_class(Types::BaseConnection)
-
field_class Types::BaseField
-
end
-
end
-
# frozen_string_literal: true
-
-
module Types
-
class BaseScalar < GraphQL::Schema::Scalar
-
end
-
end
-
# frozen_string_literal: true
-
-
module Types
-
class BaseUnion < GraphQL::Schema::Union
-
edge_type_class(Types::BaseEdge)
-
connection_type_class(Types::BaseConnection)
-
end
-
end
-
module Types
-
class CategoryType < Types::BaseObject
-
description "A category (hierarchical term)"
-
-
field :id, ID, null: false
-
field :name, String, null: false
-
field :slug, String, null: false
-
field :description, String, null: true
-
field :count, Integer, null: false
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Hierarchy
-
field :parent, Types::CategoryType, null: true
-
field :children, [Types::CategoryType], null: true
-
-
# Associated content
-
field :posts, [Types::PostType], null: true do
-
argument :limit, Integer, required: false
-
end
-
-
def posts(limit: nil)
-
posts = Post.joins(:term_relationships).where(term_relationships: { term_id: object.id })
-
posts = posts.published
-
posts = posts.limit(limit) if limit
-
posts
-
end
-
end
-
end
-
-
-
module Types
-
class ChannelOverrideType < Types::BaseObject
-
description "A channel override for customizing content per channel"
-
-
field :id, ID, null: false
-
field :kind, String, null: false, description: "Type of override: 'override' or 'exclude'"
-
field :path, String, null: false, description: "JSON path to the field being overridden"
-
field :data, GraphQL::Types::JSON, null: true, description: "Override data"
-
field :enabled, Boolean, null: false
-
field :resource_type, String, null: false
-
field :resource_id, Integer, null: true
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Associations
-
field :channel, Types::ChannelType, null: false
-
field :resource, Types::NodeType, null: true
-
-
# Computed fields
-
field :is_override, Boolean, null: false
-
field :is_exclusion, Boolean, null: false
-
field :resource_name, String, null: true
-
-
def is_override
-
object.kind == 'override'
-
end
-
-
def is_exclusion
-
object.kind == 'exclude'
-
end
-
-
def resource_name
-
return nil unless object.resource_id
-
-
case object.resource_type
-
when 'Post'
-
Post.find_by(id: object.resource_id)&.title
-
when 'Page'
-
Page.find_by(id: object.resource_id)&.title
-
when 'Medium'
-
Medium.find_by(id: object.resource_id)&.title
-
else
-
"#{object.resource_type} ##{object.resource_id}"
-
end
-
end
-
end
-
end
-
module Types
-
class ChannelType < Types::BaseObject
-
description "A content channel for distributing content across different platforms"
-
-
field :id, ID, null: false
-
field :name, String, null: false
-
field :slug, String, null: false
-
field :domain, String, null: true
-
field :locale, String, null: false
-
field :enabled, Boolean, null: false
-
field :metadata, GraphQL::Types::JSON, null: true
-
field :settings, GraphQL::Types::JSON, null: true
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Associations
-
field :posts, [Types::PostType], null: true
-
field :pages, [Types::PageType], null: true
-
field :media, [Types::MediumType], null: true
-
field :overrides, [Types::ChannelOverrideType], null: true
-
-
# Computed fields
-
field :content_count, Integer, null: false
-
field :override_count, Integer, null: false
-
field :device_type, String, null: true
-
field :target_audience, String, null: true
-
-
def content_count
-
object.posts.count + object.pages.count + object.media.count
-
end
-
-
def override_count
-
object.channel_overrides.count
-
end
-
-
def device_type
-
object.metadata&.dig('device_type')
-
end
-
-
def target_audience
-
object.metadata&.dig('target_audience')
-
end
-
end
-
end
-
module Types
-
class CommentType < Types::BaseObject
-
description "A comment on a post or page"
-
-
field :id, ID, null: false
-
field :content, String, null: false
-
field :author_name, String, null: true
-
field :author_email, String, null: true
-
field :status, String, null: false
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Author (user)
-
field :user, Types::UserType, null: true
-
-
# Commentable (polymorphic)
-
field :commentable_type, String, null: false
-
field :commentable_id, ID, null: false
-
field :post, Types::PostType, null: true
-
field :page, Types::PageType, null: true
-
-
# Threading
-
field :parent, Types::CommentType, null: true
-
field :replies, [Types::CommentType], null: true
-
-
def post
-
object.commentable if object.commentable_type == 'Post'
-
end
-
-
def page
-
object.commentable if object.commentable_type == 'Page'
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
module Types
-
class ContentTypeType < Types::BaseObject
-
description "A content type (custom post type)"
-
-
field :id, ID, null: false
-
field :ident, String, null: false, description: "Unique identifier for the content type"
-
field :label, String, null: false, description: "Display label"
-
field :singular, String, null: false, description: "Singular name"
-
field :plural, String, null: false, description: "Plural name"
-
field :description, String, null: true, description: "Description of the content type"
-
field :icon, String, null: true, description: "Icon name"
-
field :public, Boolean, null: false, description: "Is visible on frontend"
-
field :hierarchical, Boolean, null: false, description: "Supports parent/child relationships"
-
field :has_archive, Boolean, null: false, description: "Has archive page"
-
field :menu_position, Integer, null: true, description: "Position in admin menu"
-
field :supports, [String], null: false, description: "Features this type supports"
-
field :capabilities, GraphQL::Types::JSON, null: true, description: "Custom capabilities"
-
field :rest_base, String, null: false, description: "REST API endpoint base"
-
field :active, Boolean, null: false, description: "Is currently active"
-
field :posts_count, Integer, null: false, description: "Number of posts of this type"
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
field :posts, [Types::PostType], null: false, description: "Posts of this content type"
-
-
def posts_count
-
object.posts.count
-
end
-
-
def rest_base
-
object.rest_endpoint
-
end
-
end
-
end
-
-
-
-
-
-
module Types
-
class GdprType < Types::BaseObject
-
description "GDPR compliance information"
-
-
field :user_id, ID, null: false, description: "User ID"
-
field :email, String, null: false, description: "User email"
-
-
field :compliance_status, Types::GdprComplianceStatusType, null: false, description: "GDPR compliance status"
-
field :data_retention, Types::GdprDataRetentionType, null: false, description: "Data retention information"
-
field :pending_requests, Types::GdprPendingRequestsType, null: false, description: "Pending GDPR requests"
-
field :data_categories, Types::GdprDataCategoriesType, null: false, description: "Data categories held"
-
field :legal_basis, Types::GdprLegalBasisType, null: false, description: "Legal basis for processing"
-
-
field :export_requests, [Types::GdprExportRequestType], null: false, description: "Data export requests"
-
field :erasure_requests, [Types::GdprErasureRequestType], null: false, description: "Data erasure requests"
-
field :consent_records, [Types::GdprConsentRecordType], null: false, description: "User consent records"
-
-
def export_requests
-
PersonalDataExportRequest.where(user_id: object[:user_id])
-
end
-
-
def erasure_requests
-
PersonalDataErasureRequest.where(user_id: object[:user_id])
-
end
-
-
def consent_records
-
UserConsent.where(user_id: object[:user_id])
-
end
-
end
-
-
class GdprComplianceStatusType < Types::BaseObject
-
description "GDPR compliance status"
-
-
field :data_processing_consent, String, null: false, description: "Data processing consent status"
-
field :marketing_consent, String, null: false, description: "Marketing consent status"
-
field :analytics_consent, String, null: false, description: "Analytics consent status"
-
field :cookie_consent, String, null: false, description: "Cookie consent status"
-
end
-
-
class GdprDataRetentionType < Types::BaseObject
-
description "Data retention information"
-
-
field :account_created, GraphQL::Types::ISO8601DateTime, null: false, description: "Account creation date"
-
field :last_activity, GraphQL::Types::ISO8601DateTime, null: true, description: "Last activity date"
-
field :data_age_days, Integer, null: false, description: "Data age in days"
-
end
-
-
class GdprPendingRequestsType < Types::BaseObject
-
description "Pending GDPR requests"
-
-
field :export_requests, Integer, null: false, description: "Number of pending export requests"
-
field :erasure_requests, Integer, null: false, description: "Number of pending erasure requests"
-
end
-
-
class GdprDataCategoriesType < Types::BaseObject
-
description "Data categories held"
-
-
field :profile_data, Boolean, null: false, description: "Profile data held"
-
field :content_data, Boolean, null: false, description: "Content data held"
-
field :communication_data, Boolean, null: false, description: "Communication data held"
-
field :analytics_data, Boolean, null: false, description: "Analytics data held"
-
field :media_data, Boolean, null: false, description: "Media data held"
-
field :subscription_data, Boolean, null: false, description: "Subscription data held"
-
end
-
-
class GdprLegalBasisType < Types::BaseObject
-
description "Legal basis for processing"
-
-
field :consent, Boolean, null: false, description: "Processing based on consent"
-
field :withhold_consent, Boolean, null: false, description: "Consent has been withdrawn"
-
field :legitimate_interest, Boolean, null: false, description: "Processing based on legitimate interest"
-
end
-
-
class GdprExportRequestType < Types::BaseObject
-
description "GDPR data export request"
-
-
field :id, ID, null: false, description: "Request ID"
-
field :email, String, null: false, description: "User email"
-
field :status, String, null: false, description: "Request status"
-
field :requested_at, GraphQL::Types::ISO8601DateTime, null: false, description: "Request date"
-
field :completed_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Completion date"
-
field :download_url, String, null: true, description: "Download URL (if completed)"
-
field :token, String, null: false, description: "Access token"
-
end
-
-
class GdprErasureRequestType < Types::BaseObject
-
description "GDPR data erasure request"
-
-
field :id, ID, null: false, description: "Request ID"
-
field :email, String, null: false, description: "User email"
-
field :status, String, null: false, description: "Request status"
-
field :reason, String, null: true, description: "Erasure reason"
-
field :requested_at, GraphQL::Types::ISO8601DateTime, null: false, description: "Request date"
-
field :confirmed_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Confirmation date"
-
field :completed_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Completion date"
-
field :confirmation_url, String, null: true, description: "Confirmation URL (if pending)"
-
field :metadata, GraphQL::Types::JSON, null: true, description: "Request metadata"
-
end
-
-
class GdprConsentRecordType < Types::BaseObject
-
description "GDPR consent record"
-
-
field :id, ID, null: false, description: "Record ID"
-
field :consent_type, String, null: false, description: "Type of consent"
-
field :granted, Boolean, null: false, description: "Whether consent is granted"
-
field :consent_text, String, null: false, description: "Consent text"
-
field :granted_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Grant date"
-
field :withdrawn_at, GraphQL::Types::ISO8601DateTime, null: true, description: "Withdrawal date"
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false, description: "Creation date"
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false, description: "Last update date"
-
end
-
-
class GdprDataPortabilityType < Types::BaseObject
-
description "GDPR data portability information"
-
-
field :user_profile, GraphQL::Types::JSON, null: false, description: "User profile data"
-
field :posts, [GraphQL::Types::JSON], null: false, description: "User posts"
-
field :pages, [GraphQL::Types::JSON], null: false, description: "User pages"
-
field :comments, [GraphQL::Types::JSON], null: false, description: "User comments"
-
field :media, [GraphQL::Types::JSON], null: false, description: "User media"
-
field :subscribers, [GraphQL::Types::JSON], null: false, description: "User subscriptions"
-
field :api_tokens, [GraphQL::Types::JSON], null: false, description: "User API tokens"
-
field :meta_fields, [GraphQL::Types::JSON], null: false, description: "User meta fields"
-
field :analytics_data, GraphQL::Types::JSON, null: false, description: "User analytics data"
-
field :consent_records, [GraphQL::Types::JSON], null: false, description: "User consent records"
-
field :gdpr_requests, GraphQL::Types::JSON, null: false, description: "GDPR requests history"
-
field :metadata, GraphQL::Types::JSON, null: false, description: "Export metadata"
-
end
-
-
class GdprAuditLogEntryType < Types::BaseObject
-
description "GDPR audit log entry"
-
-
field :id, ID, null: false, description: "Entry ID"
-
field :action, String, null: false, description: "Action performed"
-
field :user_email, String, null: false, description: "User email"
-
field :timestamp, GraphQL::Types::ISO8601DateTime, null: false, description: "Action timestamp"
-
field :details, GraphQL::Types::JSON, null: true, description: "Action details"
-
end
-
end
-
module Types
-
class ImageOptimizationLogType < Types::BaseObject
-
description "Image optimization log entry"
-
-
field :id, ID, null: false
-
field :filename, String, null: true
-
field :content_type, String, null: true
-
field :original_size, Integer, null: true
-
field :optimized_size, Integer, null: true
-
field :bytes_saved, Integer, null: true
-
field :size_reduction_percentage, Float, null: true
-
field :size_reduction_mb, Float, null: true
-
field :compression_level, String, null: true
-
field :compression_level_name, String, null: true
-
field :quality, Integer, null: true
-
field :processing_time, Float, null: true
-
field :processing_time_formatted, String, null: true
-
field :status, String, null: true
-
field :optimization_type, String, null: true
-
field :variants_generated, [String], null: true
-
field :responsive_variants_generated, [String], null: true
-
field :error_message, String, null: true
-
field :warnings, [String], null: true
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: true
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: true
-
-
field :user, Types::UserType, null: true
-
field :medium, Types::MediumType, null: true
-
field :upload, Types::UploadType, null: true
-
-
def compression_level_name
-
ImageOptimizationService.available_compression_levels[object.compression_level]&.dig(:name) || object.compression_level&.capitalize
-
end
-
-
def size_reduction_mb
-
object.size_reduction_mb
-
end
-
-
def processing_time_formatted
-
object.processing_time_formatted
-
end
-
end
-
-
class ImageOptimizationStatsType < Types::BaseObject
-
description "Image optimization statistics"
-
-
field :total_optimizations, Integer, null: false
-
field :successful_optimizations, Integer, null: false
-
field :failed_optimizations, Integer, null: false
-
field :skipped_optimizations, Integer, null: false
-
field :total_bytes_saved, Integer, null: false
-
field :total_size_saved_mb, Float, null: false
-
field :average_size_reduction, Float, null: true
-
field :average_processing_time, Float, null: true
-
field :today_optimizations, Integer, null: false
-
field :this_week_optimizations, Integer, null: false
-
field :this_month_optimizations, Integer, null: false
-
end
-
-
class CompressionLevelType < Types::BaseObject
-
description "Compression level configuration"
-
-
field :name, String, null: false
-
field :description, String, null: false
-
field :quality, Integer, null: false
-
field :compression_level, Integer, null: false
-
field :lossy, Boolean, null: false
-
field :expected_savings, String, null: false
-
field :recommended_for, String, null: false
-
end
-
-
class ImageOptimizationReportType < Types::BaseObject
-
description "Image optimization report"
-
-
field :total_optimizations, Integer, null: false
-
field :successful_optimizations, Integer, null: false
-
field :failed_optimizations, Integer, null: false
-
field :skipped_optimizations, Integer, null: false
-
field :total_bytes_saved, Integer, null: false
-
field :total_size_saved_mb, Float, null: false
-
field :average_size_reduction, Float, null: true
-
field :average_processing_time, Float, null: true
-
field :compression_level_breakdown, GraphQL::Types::JSON, null: true
-
field :optimization_type_breakdown, GraphQL::Types::JSON, null: true
-
field :daily_optimizations, GraphQL::Types::JSON, null: true
-
field :top_users, GraphQL::Types::JSON, null: true
-
field :top_tenants, GraphQL::Types::JSON, null: true
-
end
-
end
-
module Types
-
class MediaType < Types::BaseObject
-
field :id, ID, null: false
-
field :title, String, null: false
-
field :description, String, null: true
-
field :alt_text, String, null: true
-
-
# File information (from upload)
-
field :filename, String, null: true
-
field :content_type, String, null: true
-
field :file_size, Integer, null: true
-
field :url, String, null: true
-
-
# File type flags
-
field :image, Boolean, null: false
-
field :video, Boolean, null: false
-
field :document, Boolean, null: false
-
-
# Security status
-
field :quarantined, Boolean, null: false
-
field :quarantine_reason, String, null: true
-
field :approved, Boolean, null: false
-
-
# Timestamps
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Relationships
-
field :user, Types::UserType, null: false
-
field :upload, Types::UploadType, null: false
-
-
# Helper methods for file type flags
-
def image
-
object.image?
-
end
-
-
def video
-
object.video?
-
end
-
-
def document
-
object.document?
-
end
-
-
def quarantined
-
object.quarantined?
-
end
-
-
def approved
-
object.approved?
-
end
-
-
def file_size
-
object.file_size
-
end
-
end
-
end
-
-
module Types
-
class MediumType < Types::BaseObject
-
description "A media file with channel support"
-
-
field :id, ID, null: false
-
field :title, String, null: false
-
field :file_name, String, null: false
-
field :file_type, String, null: false
-
field :description, String, null: true
-
field :alt_text, String, null: true
-
field :file_size, Integer, null: true
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Channel support
-
field :channels, [Types::ChannelType], null: true
-
field :channel_context, String, null: true, description: "Current channel context"
-
field :provenance, GraphQL::Types::JSON, null: true, description: "Data provenance information"
-
-
# Computed fields
-
field :url, String, null: true
-
field :content_type, String, null: false
-
field :file_extension, String, null: true
-
field :is_image, Boolean, null: false
-
field :is_video, Boolean, null: false
-
field :is_document, Boolean, null: false
-
-
def url
-
object.file_url if object.respond_to?(:file_url)
-
end
-
-
def content_type
-
'media'
-
end
-
-
def file_extension
-
File.extname(object.file_name).downcase[1..-1]
-
end
-
-
def is_image
-
%w[jpg jpeg png gif webp svg].include?(file_extension)
-
end
-
-
def is_video
-
%w[mp4 avi mov wmv flv webm].include?(file_extension)
-
end
-
-
def is_document
-
%w[pdf doc docx txt rtf].include?(file_extension)
-
end
-
end
-
end
-
module Types
-
class MetaFieldInputType < Types::BaseInputObject
-
description "Input for creating or updating a meta field"
-
-
argument :key, String, required: true, description: "The key/name of the meta field"
-
argument :value, String, required: false, description: "The value of the meta field"
-
argument :immutable, Boolean, required: false, default_value: false, description: "Whether this meta field can be modified"
-
end
-
-
class MetaFieldBulkInputType < Types::BaseInputObject
-
description "Input for bulk creating or updating meta fields"
-
-
argument :meta_fields, [MetaFieldInputType], required: true, description: "Array of meta fields to create or update"
-
end
-
-
class MetaFieldUpdateInputType < Types::BaseInputObject
-
description "Input for updating an existing meta field"
-
-
argument :value, String, required: false, description: "The new value of the meta field"
-
argument :immutable, Boolean, required: false, description: "Whether this meta field can be modified"
-
end
-
end
-
-
-
-
-
module Types
-
class MetaFieldType < Types::BaseObject
-
description "A meta field for storing custom data on models"
-
-
field :id, ID, null: false, description: "Unique identifier for the meta field"
-
field :key, String, null: false, description: "The key/name of the meta field"
-
field :value, String, null: true, description: "The value of the meta field"
-
field :immutable, Boolean, null: false, description: "Whether this meta field can be modified"
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false, description: "When the meta field was created"
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false, description: "When the meta field was last updated"
-
field :metable_type, String, null: false, description: "The type of object this meta field belongs to"
-
field :metable_id, ID, null: false, description: "The ID of the object this meta field belongs to"
-
-
# Helper method to get the parent object
-
field :metable, GraphQL::Types::JSON, null: true, description: "The parent object this meta field belongs to" do
-
def resolve(object, args, context)
-
# Return basic info about the metable without exposing the full object
-
{
-
type: object.metable_type,
-
id: object.metable_id
-
}
-
end
-
end
-
-
# JSON value helper
-
field :json_value, GraphQL::Types::JSON, null: true, description: "The value parsed as JSON if valid" do
-
def resolve(object, args, context)
-
object.json_value
-
end
-
end
-
-
# Type-casted value helpers
-
field :int_value, Integer, null: true, description: "The value as an integer" do
-
def resolve(object, args, context)
-
object.to_i
-
end
-
end
-
-
field :float_value, Float, null: true, description: "The value as a float" do
-
def resolve(object, args, context)
-
object.to_f
-
end
-
end
-
-
field :bool_value, Boolean, null: true, description: "The value as a boolean" do
-
def resolve(object, args, context)
-
object.to_bool
-
end
-
end
-
end
-
end
-
-
-
-
-
module Types
-
class MutationType < Types::BaseObject
-
description "The mutation root of the RailsPress GraphQL API"
-
-
# Example mutations - can be expanded
-
# TODO: Add full CRUD mutations for posts, pages, comments, etc.
-
-
field :test_field, String, null: false do
-
description "An example field added by the generator"
-
end
-
-
def test_field
-
"Hello World from RailsPress GraphQL!"
-
end
-
-
# ========== IMAGE OPTIMIZATION MUTATIONS ==========
-
-
field :bulk_optimize_images, GraphQL::Types::Boolean, null: false do
-
description "Start bulk optimization of images"
-
end
-
-
field :regenerate_image_variants, GraphQL::Types::Boolean, null: false do
-
description "Regenerate image variants"
-
argument :medium_id, ID, required: true
-
end
-
-
field :clear_optimization_logs, GraphQL::Types::Boolean, null: false do
-
description "Clear all optimization logs"
-
argument :confirm, Boolean, required: true
-
end
-
-
def bulk_optimize_images
-
# Get all unoptimized images
-
unoptimized_uploads = Upload.joins(:media)
-
.where(media: { id: Medium.where.not(id: ImageOptimizationLog.select(:medium_id)) })
-
.where.not(file: nil)
-
-
return true if unoptimized_uploads.empty?
-
-
# Queue optimization jobs
-
unoptimized_uploads.limit(100).each do |upload|
-
medium = upload.media.first
-
if medium
-
OptimizeImageJob.perform_later(
-
medium_id: medium.id,
-
optimization_type: 'bulk',
-
request_context: {
-
user_agent: context[:request]&.user_agent,
-
ip_address: context[:request]&.remote_ip
-
}
-
)
-
end
-
end
-
-
true
-
end
-
-
def regenerate_image_variants(medium_id:)
-
medium = Medium.find(medium_id)
-
OptimizeImageJob.perform_later(
-
medium_id: medium.id,
-
optimization_type: 'regenerate',
-
request_context: {
-
user_agent: context[:request]&.user_agent,
-
ip_address: context[:request]&.remote_ip
-
}
-
)
-
true
-
end
-
-
def clear_optimization_logs(confirm:)
-
return false unless confirm
-
-
ImageOptimizationLog.delete_all
-
true
-
end
-
-
# ========== GDPR COMPLIANCE MUTATIONS ==========
-
-
field :request_data_export, mutation: Mutations::GdprMutations::RequestDataExport
-
field :request_data_erasure, mutation: Mutations::GdprMutations::RequestDataErasure
-
field :confirm_data_erasure, mutation: Mutations::GdprMutations::ConfirmDataErasure
-
field :record_consent, mutation: Mutations::GdprMutations::RecordConsent
-
field :withdraw_consent, mutation: Mutations::GdprMutations::WithdrawConsent
-
end
-
end
-
-
-
-
-
-
-
-
-
# frozen_string_literal: true
-
-
module Types
-
module NodeType
-
include Types::BaseInterface
-
# Add the `id` field
-
include GraphQL::Types::Relay::NodeBehaviors
-
end
-
end
-
module Types
-
class PageType < Types::BaseObject
-
description "A page with channel support"
-
-
field :id, ID, null: false
-
field :title, String, null: false
-
field :slug, String, null: false
-
field :content, String, null: true
-
field :status, String, null: false
-
field :published_at, GraphQL::Types::ISO8601DateTime, null: true
-
field :parent_id, Integer, null: true
-
field :order, Integer, null: true
-
field :template, String, null: true
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Channel support
-
field :channels, [Types::ChannelType], null: true
-
field :channel_context, String, null: true, description: "Current channel context"
-
field :provenance, GraphQL::Types::JSON, null: true, description: "Data provenance information"
-
-
# Associations
-
field :user, Types::UserType, null: true
-
field :parent, Types::PageType, null: true
-
field :children, [Types::PageType], null: true
-
-
# Computed fields
-
field :url, String, null: true
-
field :author_name, String, null: true
-
field :content_type, String, null: false
-
-
def url
-
Rails.application.routes.url_helpers.page_url(object, host: context[:request]&.host)
-
rescue
-
nil
-
end
-
-
def author_name
-
object.user&.display_name || object.user&.email
-
end
-
-
def content_type
-
'page'
-
end
-
end
-
end
-
module Types
-
class PostType < Types::BaseObject
-
description "A blog post with channel support"
-
-
field :id, ID, null: false
-
field :title, String, null: false
-
field :slug, String, null: false
-
field :content, String, null: true
-
field :excerpt, String, null: true
-
field :status, String, null: false
-
field :published_at, GraphQL::Types::ISO8601DateTime, null: true
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Channel support
-
field :channels, [Types::ChannelType], null: true
-
field :channel_context, String, null: true, description: "Current channel context"
-
field :provenance, GraphQL::Types::JSON, null: true, description: "Data provenance information"
-
-
# Associations
-
field :user, Types::UserType, null: true
-
field :categories, [Types::TermType], null: true
-
field :tags, [Types::TermType], null: true
-
field :comments, [Types::CommentType], null: true
-
-
# Computed fields
-
field :url, String, null: true
-
field :author_name, String, null: true
-
field :content_type, String, null: false
-
-
def url
-
Rails.application.routes.url_helpers.blog_post_url(object, host: context[:request]&.host)
-
rescue
-
nil
-
end
-
-
def author_name
-
object.user&.display_name || object.user&.email
-
end
-
-
def content_type
-
'post'
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Types
-
class QueryType < Types::BaseObject
-
field :node, Types::NodeType, null: true, description: "Fetches an object given its ID." do
-
argument :id, ID, required: true, description: "ID of the object."
-
end
-
-
def node(id:)
-
context.schema.object_from_id(id, context)
-
end
-
-
field :nodes, [Types::NodeType, null: true], null: true, description: "Fetches a list of objects given a list of IDs." do
-
argument :ids, [ID], required: true, description: "IDs of the objects."
-
end
-
-
def nodes(ids:)
-
ids.map { |id| context.schema.object_from_id(id, context) }
-
end
-
-
# Add root-level fields here.
-
# They will be entry points for queries on your schema.
-
-
# Channel queries
-
field :channels, resolver: Resolvers::ChannelsResolver, description: "Find channels"
-
field :channel, resolver: Resolvers::ChannelResolver, description: "Find a single channel"
-
-
# Content queries with channel support
-
field :posts, resolver: Resolvers::PostsResolver, description: "Find posts with channel filtering"
-
field :pages, resolver: Resolvers::PagesResolver, description: "Find pages with channel filtering"
-
field :media, resolver: Resolvers::MediaResolver, description: "Find media with channel filtering"
-
-
# TODO: remove me
-
field :test_field, String, null: false,
-
description: "An example field added by the generator"
-
def test_field
-
"Hello World!"
-
end
-
end
-
end
-
module Types
-
class SearchResultsType < Types::BaseObject
-
description "Search results across posts and pages"
-
-
field :posts, [Types::PostType], null: false
-
field :pages, [Types::PageType], null: false
-
field :total, Integer, null: false
-
end
-
end
-
-
-
-
-
-
-
-
-
module Types
-
class StorageProviderType < Types::BaseObject
-
field :id, ID, null: false
-
field :name, String, null: false
-
field :provider_type, String, null: false
-
field :active, Boolean, null: false
-
field :position, Integer, null: true
-
-
# Provider type flags
-
field :local, Boolean, null: false
-
field :s3, Boolean, null: false
-
field :gcs, Boolean, null: false
-
field :azure, Boolean, null: false
-
-
# Timestamps
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Helper methods for provider type flags
-
def local
-
object.local?
-
end
-
-
def s3
-
object.s3?
-
end
-
-
def gcs
-
object.gcs?
-
end
-
-
def azure
-
object.azure?
-
end
-
end
-
end
-
-
module Types
-
class SubscriberType < Types::BaseObject
-
description "A newsletter subscriber"
-
-
field :id, ID, null: false
-
field :email, String, null: false
-
field :name, String, null: true
-
field :status, String, null: false
-
field :source, String, null: true
-
field :tags, [String], null: true
-
field :lists, [String], null: true
-
field :confirmed_at, GraphQL::Types::ISO8601DateTime, null: true
-
field :unsubscribed_at, GraphQL::Types::ISO8601DateTime, null: true
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Check if can receive emails
-
field :can_receive_emails, Boolean, null: false
-
-
def can_receive_emails
-
object.can_receive_emails?
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
module Types
-
class TagType < Types::BaseObject
-
description "A tag (non-hierarchical term)"
-
-
field :id, ID, null: false
-
field :name, String, null: false
-
field :slug, String, null: false
-
field :description, String, null: true
-
field :count, Integer, null: false
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Associated content
-
field :posts, [Types::PostType], null: true do
-
argument :limit, Integer, required: false
-
end
-
-
def posts(limit: nil)
-
posts = Post.joins(:term_relationships).where(term_relationships: { term_id: object.id })
-
posts = posts.published
-
posts = posts.limit(limit) if limit
-
posts
-
end
-
end
-
end
-
-
-
module Types
-
class TaxonomyType < Types::BaseObject
-
description "A custom taxonomy"
-
-
field :id, ID, null: false
-
field :name, String, null: false
-
field :slug, String, null: false
-
field :description, String, null: true
-
field :hierarchical, Boolean, null: false
-
field :object_types, [String], null: true
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Terms
-
field :terms, [Types::TermType], null: true do
-
argument :parent_id, ID, required: false
-
argument :limit, Integer, required: false
-
end
-
-
# Counts
-
field :term_count, Integer, null: false
-
-
def terms(parent_id: nil, limit: nil)
-
terms = object.terms
-
if parent_id
-
terms = terms.where(parent_id: parent_id)
-
elsif object.hierarchical
-
terms = terms.where(parent_id: nil) # Only root terms for hierarchical
-
end
-
terms = terms.limit(limit) if limit
-
terms
-
end
-
-
def term_count
-
object.terms.count
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
module Types
-
class TermType < Types::BaseObject
-
description "A taxonomy term"
-
-
field :id, ID, null: false
-
field :name, String, null: false
-
field :slug, String, null: false
-
field :description, String, null: true
-
field :count, Integer, null: false
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Taxonomy
-
field :taxonomy, Types::TaxonomyType, null: false
-
-
# Hierarchy
-
field :parent, Types::TermType, null: true
-
field :children, [Types::TermType], null: true
-
-
# Associated content
-
field :posts, [Types::PostType], null: true do
-
argument :limit, Integer, required: false
-
end
-
-
field :pages, [Types::PageType], null: true do
-
argument :limit, Integer, required: false
-
end
-
-
def posts(limit: nil)
-
posts = Post.joins(:term_relationships).where(term_relationships: { term_id: object.id })
-
posts = posts.published
-
posts = posts.limit(limit) if limit
-
posts
-
end
-
-
def pages(limit: nil)
-
pages = Page.joins(:term_relationships).where(term_relationships: { term_id: object.id })
-
pages = pages.published
-
pages = pages.limit(limit) if limit
-
pages
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
module Types
-
class UploadType < Types::BaseObject
-
field :id, ID, null: false
-
field :title, String, null: false
-
field :description, String, null: true
-
field :alt_text, String, null: true
-
-
# File information
-
field :filename, String, null: true
-
field :content_type, String, null: true
-
field :file_size, Integer, null: true
-
field :url, String, null: true
-
-
# File type flags
-
field :image, Boolean, null: false
-
field :video, Boolean, null: false
-
field :document, Boolean, null: false
-
-
# Security status
-
field :quarantined, Boolean, null: false
-
field :quarantine_reason, String, null: true
-
field :approved, Boolean, null: false
-
-
# Timestamps
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Relationships
-
field :user, Types::UserType, null: false
-
field :storage_provider, Types::StorageProviderType, null: true
-
field :media, [Types::MediaType], null: false
-
-
# Helper methods for file type flags
-
def image
-
object.image?
-
end
-
-
def video
-
object.video?
-
end
-
-
def document
-
object.document?
-
end
-
-
def quarantined
-
object.quarantined?
-
end
-
-
def approved
-
object.approved?
-
end
-
-
def file_size
-
object.file_size
-
end
-
end
-
end
-
-
module Types
-
class UserType < Types::BaseObject
-
description "A user in the system"
-
-
field :id, ID, null: false
-
field :email, String, null: false
-
field :role, String, null: false
-
field :created_at, GraphQL::Types::ISO8601DateTime, null: false
-
field :updated_at, GraphQL::Types::ISO8601DateTime, null: false
-
-
# Associations
-
field :posts, [Types::PostType], null: true do
-
description "Posts created by this user"
-
argument :status, String, required: false
-
argument :limit, Integer, required: false
-
end
-
-
field :pages, [Types::PageType], null: true do
-
description "Pages created by this user"
-
argument :status, String, required: false
-
argument :limit, Integer, required: false
-
end
-
-
field :comments, [Types::CommentType], null: true do
-
description "Comments by this user"
-
argument :limit, Integer, required: false
-
end
-
-
# Computed fields
-
field :post_count, Integer, null: false
-
field :page_count, Integer, null: false
-
field :is_admin, Boolean, null: false
-
-
# Meta Fields
-
field :meta_fields, [Types::MetaFieldType], null: true, description: "Custom meta fields for this user" do
-
argument :key, String, required: false, description: "Filter by specific meta field key"
-
argument :immutable, Boolean, required: false, description: "Filter by immutable status"
-
end
-
-
field :meta_field, Types::MetaFieldType, null: true, description: "Get a specific meta field by key" do
-
argument :key, String, required: true, description: "The key of the meta field to retrieve"
-
end
-
-
field :all_meta, GraphQL::Types::JSON, null: true, description: "All meta fields as a key-value hash"
-
-
def posts(status: nil, limit: nil)
-
posts = object.posts
-
posts = posts.where(status: status) if status
-
posts = posts.limit(limit) if limit
-
posts
-
end
-
-
def pages(status: nil, limit: nil)
-
pages = object.pages
-
pages = pages.where(status: status) if status
-
pages = pages.limit(limit) if limit
-
pages
-
end
-
-
def comments(limit: nil)
-
comments = object.comments
-
comments = comments.limit(limit) if limit
-
comments
-
end
-
-
def post_count
-
object.posts.count
-
end
-
-
def page_count
-
object.pages.count
-
end
-
-
def is_admin
-
object.administrator?
-
end
-
-
def meta_fields(key: nil, immutable: nil)
-
meta_fields = object.meta_fields
-
meta_fields = meta_fields.by_key(key) if key.present?
-
meta_fields = meta_fields.immutable if immutable == true
-
meta_fields = meta_fields.mutable if immutable == false
-
meta_fields
-
end
-
-
def meta_field(key:)
-
object.meta_fields.find_by(key: key)
-
end
-
-
def all_meta
-
object.all_meta
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
1
module Admin::AiAgentsHelper
-
end
-
1
module Admin::AiHelper
-
# Render an AI Assistant button that opens the AI popup
-
1
def ai_assistant_button(options = {})
-
options[:class] ||= "flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition text-sm"
-
options[:text] ||= "AI Assistant"
-
-
content_tag :button, type: "button", onclick: "openAiPopup()", class: options[:class] do
-
content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
-
content_tag(:path, "", stroke_linecap: "round", stroke_linejoin: "round", stroke_width: "2", d: "M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z")
-
end +
-
content_tag(:span, options[:text])
-
end
-
end
-
-
# Render the AI popup modal (call this once per page)
-
1
def ai_popup_modal
-
render 'shared/ai_popup'
-
end
-
-
# Render an AI button with content editor label
-
1
def ai_content_editor_label(form, field, label_text = nil)
-
label_text ||= field.to_s.humanize
-
-
content_tag :div, class: "flex items-center justify-between mb-2" do
-
form.label(field, label_text, class: "block text-sm font-medium text-gray-300") +
-
ai_assistant_button
-
end
-
end
-
-
# Check if AI agents are available
-
1
def ai_agents_available?
-
AiAgent.active.any?
-
end
-
-
# Get available agent types
-
1
def available_ai_agents
-
AiAgent.active.pluck(:agent_type, :name).map { |type, name| [type, name] }
-
end
-
end
-
-
-
-
-
-
-
1
module Admin::AiProvidersHelper
-
end
-
1
module Admin::CategoriesHelper
-
end
-
1
module Admin::CommentsHelper
-
end
-
1
module Admin::DashboardHelper
-
end
-
1
module Admin::MediaHelper
-
end
-
1
module Admin::MenusHelper
-
end
-
1
module Admin::PagesHelper
-
end
-
1
module Admin::PluginsHelper
-
end
-
1
module Admin::PostsHelper
-
end
-
1
module Admin::Settings::RedisHelper
-
end
-
1
module Admin::SiteSettingsHelper
-
end
-
1
module Admin::TagsHelper
-
end
-
1
module Admin::TaxonomiesHelper
-
end
-
1
module Admin::TemplateCustomizerHelper
-
end
-
1
module Admin::TermsHelper
-
end
-
1
module Admin::ThemesHelper
-
end
-
1
module Admin::WebhooksHelper
-
1
def event_description(event)
-
descriptions = {
-
'post.created' => 'Triggered when a new post is created',
-
'post.updated' => 'Triggered when a post is updated',
-
'post.published' => 'Triggered when a post is published',
-
'post.deleted' => 'Triggered when a post is deleted',
-
'page.created' => 'Triggered when a new page is created',
-
'page.updated' => 'Triggered when a page is updated',
-
'page.published' => 'Triggered when a page is published',
-
'page.deleted' => 'Triggered when a page is deleted',
-
'comment.created' => 'Triggered when a new comment is created',
-
'comment.approved' => 'Triggered when a comment is approved',
-
'comment.spam' => 'Triggered when a comment is marked as spam',
-
'user.created' => 'Triggered when a new user is created',
-
'user.updated' => 'Triggered when a user is updated',
-
'media.uploaded' => 'Triggered when media is uploaded'
-
}
-
-
descriptions[event] || 'Webhook event description not available'
-
end
-
end
-
-
-
1
module Admin::WidgetsHelper
-
end
-
1
module AdminAssetsHelper
-
# Define CSS files needed for each admin page
-
ADMIN_PAGE_ASSETS = {
-
1
'theme_editor' => %w[admin/theme_editor theme_editor_tabs],
-
'api_docs' => %w[admin/api_docs],
-
'users' => %w[admin/shared tabulator_custom],
-
'posts' => %w[admin/shared tabulator_custom],
-
'pages' => %w[admin/shared tabulator_custom],
-
'comments' => %w[admin/shared tabulator_custom],
-
'media' => %w[admin/shared],
-
'settings' => %w[admin/shared],
-
'ai_agents' => %w[admin/shared],
-
'plugins' => %w[admin/shared],
-
'content_types' => %w[admin/shared tabulator_custom],
-
'categories' => %w[admin/shared tabulator_custom],
-
'tags' => %w[admin/shared tabulator_custom],
-
'subscribers' => %w[admin/shared tabulator_custom],
-
'analytics' => %w[admin/shared],
-
'trash' => %w[admin/shared tabulator_custom],
-
'cache' => %w[admin/shared],
-
'logs' => %w[admin/shared],
-
'integrations' => %w[admin/shared],
-
'pixels' => %w[admin/shared],
-
'pixel_preview' => %w[admin/pixel_preview],
-
'template_customizer' => %w[admin/shared],
-
'tools' => %w[admin/shared],
-
'fonts' => %w[admin/shared],
-
'terms' => %w[admin/shared tabulator_custom],
-
'field_groups' => %w[admin/shared tabulator_custom],
-
'shortcodes' => %w[admin/shared],
-
'email_logs' => %w[admin/shared],
-
'redirects' => %w[admin/shared tabulator_custom],
-
'system' => %w[admin/shared],
-
'dashboard' => %w[admin/shared]
-
}.freeze
-
-
# Define JavaScript files needed for each admin page
-
ADMIN_PAGE_JAVASCRIPT = {
-
1
'theme_editor' => %w[theme_editor_tabs_controller],
-
'posts' => %w[keyboard_shortcuts_controller],
-
'settings' => %w[appearance_preview_controller email_settings_controller post_by_email_controller],
-
'ai_agents' => %w[ai_agents_controller ai_agent_chat_controller],
-
'analytics' => %w[analytics_controller],
-
'cache' => %w[cache_controller],
-
'logs' => %w[log_viewer_controller],
-
'tools' => %w[import_tools_controller],
-
'users' => %w[tabulator_controller],
-
'pages' => %w[tabulator_controller],
-
'comments' => %w[tabulator_controller],
-
'media' => %w[media_library_controller],
-
'content_types' => %w[tabulator_controller],
-
'categories' => %w[tabulator_controller],
-
'tags' => %w[tabulator_controller],
-
'subscribers' => %w[tabulator_controller],
-
'trash' => %w[tabulator_controller],
-
'terms' => %w[tabulator_controller],
-
'field_groups' => %w[tabulator_controller],
-
'redirects' => %w[tabulator_controller]
-
}.freeze
-
-
1
def admin_stylesheets_for_page(page_name)
-
assets = ADMIN_PAGE_ASSETS[page_name.to_s] || %w[admin/shared]
-
assets.map { |asset| stylesheet_link_tag(asset, "data-turbo-track": "reload") }.join("\n").html_safe
-
end
-
-
1
def admin_javascripts_for_page(page_name)
-
assets = ADMIN_PAGE_JAVASCRIPT[page_name.to_s] || []
-
# Stimulus controllers are automatically loaded, so we just need to ensure they're available
-
# This method can be extended to load specific JS files if needed
-
"".html_safe
-
end
-
-
1
def admin_page_assets(page_name)
-
content_for :stylesheets, admin_stylesheets_for_page(page_name)
-
content_for :javascripts, admin_javascripts_for_page(page_name)
-
end
-
end
-
1
module AiTextGeneratorHelper
-
# Helper method to easily add AI text generation to form fields
-
#
-
# Usage:
-
# <%= ai_text_field(form, :title, 'Post Title', agent: 'content_summarizer') %>
-
# <%= ai_text_area(form, :content, 'Content', agent: 'creative_writer', rows: 6) %>
-
#
-
1
def ai_text_field(form, field_name, label_text, agent: 'content_summarizer', **options)
-
field_id = "#{form.object_name}_#{field_name}"
-
-
html = content_tag(:div, class: "ai-text-field-wrapper") do
-
# Label
-
concat form.label(field_name, label_text, class: "block text-sm font-medium text-gray-300 mb-2")
-
-
# Field with AI button
-
concat content_tag(:div, class: "relative") do
-
concat form.text_field(field_name,
-
class: "w-full px-4 py-2 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg focus:border-indigo-500 focus:outline-none pr-10 #{options[:class]}",
-
placeholder: options[:placeholder],
-
id: field_id,
-
**options.except(:class, :placeholder))
-
-
# Use a placeholder for the AI generator in tests
-
then: 0
if Rails.env.test?
-
concat content_tag(:div, "AI Generator Placeholder", class: "ai-text-generator", "data-agent-id" => agent, "data-target-selector" => "##{field_id}")
-
else: 0
else
-
concat render('shared/ai_text_generator',
-
agent_id: agent,
-
target_selector: "##{field_id}",
-
button_text: 'AI',
-
placeholder: options[:ai_placeholder] || "Describe what you want to generate...",
-
button_class: 'absolute top-2 right-2')
-
end
-
end
-
end
-
-
html
-
end
-
-
1
def ai_text_area(form, field_name, label_text, agent: 'content_summarizer', **options)
-
field_id = "#{form.object_name}_#{field_name}"
-
rows = options.delete(:rows) || 4
-
-
html = content_tag(:div, class: "ai-text-area-wrapper") do
-
# Label
-
concat form.label(field_name, label_text, class: "block text-sm font-medium text-gray-300 mb-2")
-
-
# Field with AI button
-
concat content_tag(:div, class: "relative") do
-
concat form.text_area(field_name,
-
class: "w-full px-4 py-3 bg-[#0a0a0a] border border-[#2a2a2a] text-white rounded-lg focus:border-indigo-500 focus:outline-none pr-12 #{options[:class]}",
-
placeholder: options[:placeholder],
-
rows: rows,
-
id: field_id,
-
**options.except(:class, :placeholder, :rows))
-
-
# Use a placeholder for the AI generator in tests
-
then: 0
if Rails.env.test?
-
concat content_tag(:div, "AI Generator Placeholder", class: "ai-text-generator", "data-agent-id" => agent, "data-target-selector" => "##{field_id}")
-
else: 0
else
-
concat render('shared/ai_text_generator',
-
agent_id: agent,
-
target_selector: "##{field_id}",
-
button_text: 'AI',
-
placeholder: options[:ai_placeholder] || "Describe what you want to generate...",
-
button_class: 'absolute top-3 right-3')
-
end
-
end
-
end
-
-
html
-
end
-
-
# Helper to check if AI agents are available
-
1
def ai_agents_available?
-
AiAgent.active.exists?
-
end
-
-
# Helper to get available AI agents for dropdown
-
1
def ai_agent_options
-
AiAgent.active.ordered.map { |agent| [agent.name, agent.id] }
-
end
-
-
# Helper to render AI text generator with custom styling
-
1
def ai_text_generator_button(agent_id:, target_selector:, **options)
-
render('shared/ai_text_generator',
-
agent_id: agent_id,
-
target_selector: target_selector,
-
button_text: options[:button_text] || 'AI',
-
placeholder: options[:placeholder] || 'Describe what you want to generate...',
-
button_class: options[:button_class] || '')
-
end
-
-
# Helper for admin forms - adds AI button to existing field
-
1
def with_ai_generator(field_html, agent_id:, target_selector:, **options)
-
content_tag(:div, class: "relative") do
-
concat field_html.html_safe
-
concat ai_text_generator_button(
-
agent_id: agent_id,
-
target_selector: target_selector,
-
**options
-
)
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
1
module AnalyticsHelper
-
# Render analytics tracking script with comprehensive GDPR compliance
-
# Automatically includes GDPR consent management, privacy controls, and data subject rights
-
#
-
# @return [String] Rendered HTML
-
1
def render_analytics_tracker
-
then: 0
else: 0
return '' if admin_page?
-
else: 0
then: 0
return '' unless analytics_enabled?
-
-
# Base analytics tracker
-
tracker = content_tag(:div, '',
-
data: {
-
controller: 'ga4-analytics',
-
'ga4-analytics-consent-required-value': analytics_require_consent?,
-
'ga4-analytics-anonymize-ip-value': analytics_anonymize_ip?,
-
'ga4-analytics-debug-value': Rails.env.development?,
-
'ga4-analytics-gdpr-enabled-value': gdpr_compliance_enabled?,
-
'ga4-analytics-data-retention-days-value': analytics_data_retention_days,
-
turbo_permanent: true
-
},
-
class: 'analytics-tracker'
-
)
-
-
# GDPR consent banner (if consent required)
-
consent_banner = ''
-
then: 0
else: 0
if analytics_require_consent?
-
consent_banner = content_tag(:div, '',
-
data: { 'ga4-analytics-target': 'consentBanner' },
-
class: 'fixed bottom-4 right-4 bg-indigo-600 text-white p-4 rounded-lg shadow-lg max-w-md hidden z-50'
-
) do
-
content_tag(:div, class: 'flex items-start space-x-3') do
-
content_tag(:div, class: 'flex-shrink-0') do
-
content_tag(:svg, class: 'w-6 h-6', fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24') do
-
content_tag(:path, '', stroke_linecap: 'round', stroke_linejoin: 'round', stroke_width: '2', d: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z')
-
end
-
end +
-
content_tag(:div, class: 'flex-1') do
-
content_tag(:h3, 'Privacy & Analytics', class: 'text-sm font-medium mb-2') +
-
content_tag(:p, analytics_consent_message, class: 'text-xs text-indigo-100 mb-3') +
-
content_tag(:div, class: 'flex flex-col space-y-2') do
-
content_tag(:div, class: 'flex space-x-2') do
-
content_tag(:button, 'Accept All',
-
data: { action: 'click->ga4-analytics#acceptAllConsent' },
-
class: 'bg-white text-indigo-600 px-3 py-1 rounded text-xs font-medium hover:bg-indigo-50 transition'
-
) +
-
content_tag(:button, 'Reject All',
-
data: { action: 'click->ga4-analytics#rejectAllConsent' },
-
class: 'bg-indigo-500 text-white px-3 py-1 rounded text-xs font-medium hover:bg-indigo-400 transition'
-
)
-
end +
-
content_tag(:button, 'Manage Preferences',
-
data: { action: 'click->ga4-analytics#showConsentPreferences' },
-
class: 'text-xs text-indigo-200 underline hover:text-white transition'
-
)
-
end
-
end
-
end
-
end
-
end
-
-
# Privacy controls panel (hidden by default)
-
privacy_controls = content_tag(:div, '',
-
data: { 'ga4-analytics-target': 'privacyControls' },
-
class: 'fixed inset-0 bg-black bg-opacity-50 hidden z-50'
-
) do
-
content_tag(:div, class: 'flex items-center justify-center min-h-screen p-4') do
-
content_tag(:div, class: 'bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-screen overflow-y-auto') do
-
content_tag(:div, class: 'p-6') do
-
content_tag(:div, class: 'flex items-center justify-between mb-6') do
-
content_tag(:h2, 'Privacy Preferences', class: 'text-xl font-semibold text-gray-900') +
-
content_tag(:button, '×',
-
data: { action: 'click->ga4-analytics#hideConsentPreferences' },
-
class: 'text-gray-400 hover:text-gray-600 text-2xl font-bold'
-
)
-
end +
-
content_tag(:div, class: 'space-y-6') do
-
# Essential cookies (always required)
-
content_tag(:div) do
-
content_tag(:h3, 'Essential Cookies', class: 'text-lg font-medium text-gray-900 mb-2') +
-
content_tag(:p, 'These cookies are necessary for the website to function and cannot be switched off.', class: 'text-sm text-gray-600 mb-4') +
-
content_tag(:div, class: 'flex items-center justify-between') do
-
content_tag(:span, 'Always Active', class: 'text-sm font-medium text-green-600') +
-
content_tag(:div, class: 'w-12 h-6 bg-green-500 rounded-full flex items-center justify-end px-1') do
-
content_tag(:div, '', class: 'w-4 h-4 bg-white rounded-full')
-
end
-
end
-
end +
-
-
# Analytics cookies
-
content_tag(:div) do
-
content_tag(:h3, 'Analytics Cookies', class: 'text-lg font-medium text-gray-900 mb-2') +
-
content_tag(:p, 'These cookies help us understand how visitors interact with our website by collecting and reporting information anonymously.', class: 'text-sm text-gray-600 mb-4') +
-
content_tag(:div, class: 'flex items-center justify-between') do
-
content_tag(:span, 'Analytics Tracking', class: 'text-sm font-medium text-gray-700') +
-
content_tag(:button, '',
-
data: { action: 'click->ga4-analytics#toggleAnalyticsConsent' },
-
class: 'w-12 h-6 bg-gray-300 rounded-full flex items-center px-1 transition-colors'
-
) do
-
content_tag(:div, '', class: 'w-4 h-4 bg-white rounded-full shadow-md transition-transform')
-
end
-
end
-
end +
-
-
# Marketing cookies
-
content_tag(:div) do
-
content_tag(:h3, 'Marketing Cookies', class: 'text-lg font-medium text-gray-900 mb-2') +
-
content_tag(:p, 'These cookies are used to track visitors across websites to display relevant and engaging advertisements.', class: 'text-sm text-gray-600 mb-4') +
-
content_tag(:div, class: 'flex items-center justify-between') do
-
content_tag(:span, 'Marketing Tracking', class: 'text-sm font-medium text-gray-700') +
-
content_tag(:button, '',
-
data: { action: 'click->ga4-analytics#toggleMarketingConsent' },
-
class: 'w-12 h-6 bg-gray-300 rounded-full flex items-center px-1 transition-colors'
-
) do
-
content_tag(:div, '', class: 'w-4 h-4 bg-white rounded-full shadow-md transition-transform')
-
end
-
end
-
end +
-
-
# Data subject rights
-
content_tag(:div, class: 'border-t pt-6') do
-
content_tag(:h3, 'Your Rights', class: 'text-lg font-medium text-gray-900 mb-4') +
-
content_tag(:div, class: 'grid grid-cols-1 md:grid-cols-2 gap-4') do
-
content_tag(:button, 'Access My Data',
-
data: { action: 'click->ga4-analytics#requestDataAccess' },
-
class: 'text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition'
-
) do
-
content_tag(:div, class: 'font-medium text-gray-900') { 'Access My Data' } +
-
content_tag(:div, class: 'text-sm text-gray-600') { 'Download a copy of your personal data' }
-
end +
-
content_tag(:button, 'Delete My Data',
-
data: { action: 'click->ga4-analytics#requestDataDeletion' },
-
class: 'text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition'
-
) do
-
content_tag(:div, class: 'font-medium text-gray-900') { 'Delete My Data' } +
-
content_tag(:div, class: 'text-sm text-gray-600') { 'Request deletion of your personal data' }
-
end +
-
content_tag(:button, 'Data Portability',
-
data: { action: 'click->ga4-analytics#requestDataPortability' },
-
class: 'text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition'
-
) do
-
content_tag(:div, class: 'font-medium text-gray-900') { 'Data Portability' } +
-
content_tag(:div, class: 'text-sm text-gray-600') { 'Export your data in a portable format' }
-
end +
-
content_tag(:button, 'Contact DPO',
-
data: { action: 'click->ga4-analytics#contactDPO' },
-
class: 'text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition'
-
) do
-
content_tag(:div, class: 'font-medium text-gray-900') { 'Contact DPO' } +
-
content_tag(:div, class: 'text-sm text-gray-600') { 'Contact our Data Protection Officer' }
-
end
-
end
-
end +
-
-
# Action buttons
-
content_tag(:div, class: 'flex space-x-3 pt-6 border-t') do
-
content_tag(:button, 'Save Preferences',
-
data: { action: 'click->ga4-analytics#saveConsentPreferences' },
-
class: 'flex-1 bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-indigo-700 transition'
-
) +
-
content_tag(:button, 'Accept All',
-
data: { action: 'click->ga4-analytics#acceptAllConsent' },
-
class: 'flex-1 bg-green-600 text-white px-4 py-2 rounded-lg font-medium hover:bg-green-700 transition'
-
)
-
end
-
end
-
end
-
end
-
end
-
end
-
-
tracker + consent_banner + privacy_controls
-
end
-
-
# Check if analytics is enabled
-
1
def analytics_enabled?
-
SiteSetting.get('analytics_enabled', 'true') == 'true'
-
rescue
-
true
-
end
-
-
# Check if GDPR compliance is enabled
-
1
def gdpr_compliance_enabled?
-
SiteSetting.get('gdpr_compliance_enabled', 'true') == 'true'
-
rescue
-
true
-
end
-
-
# Check if analytics requires consent
-
1
def analytics_require_consent?
-
SiteSetting.get('analytics_require_consent', 'true') == 'true'
-
rescue
-
true
-
end
-
-
# Get analytics consent message
-
1
def analytics_consent_message
-
SiteSetting.get('analytics_consent_message', 'We use privacy-friendly analytics to understand how you use our site. No personal data is collected.')
-
rescue
-
'We use privacy-friendly analytics to understand how you use our site. No personal data is collected.'
-
end
-
-
# Check if IP anonymization is enabled
-
1
def analytics_anonymize_ip?
-
SiteSetting.get('analytics_anonymize_ip', 'true') == 'true'
-
rescue
-
true
-
end
-
-
# Check if bot tracking is enabled
-
1
def analytics_track_bots?
-
SiteSetting.get('analytics_track_bots', 'false') == 'true'
-
rescue
-
false
-
end
-
-
# Get data retention period
-
1
def analytics_data_retention_days
-
SiteSetting.get('analytics_data_retention_days', 365).to_i
-
rescue
-
365
-
end
-
-
# Helper methods for content analytics views
-
-
1
def flag_for_country(country_code)
-
# Return flag emoji for country code
-
then: 0
else: 0
when: 0
case country_code&.upcase
-
when: 0
when 'US' then '🇺🇸'
-
when: 0
when 'GB' then '🇬🇧'
-
when: 0
when 'CA' then '🇨🇦'
-
when: 0
when 'AU' then '🇦🇺'
-
when: 0
when 'DE' then '🇩🇪'
-
when: 0
when 'FR' then '🇫🇷'
-
when: 0
when 'IT' then '🇮🇹'
-
when: 0
when 'ES' then '🇪🇸'
-
when: 0
when 'NL' then '🇳🇱'
-
when: 0
when 'SE' then '🇸🇪'
-
when: 0
when 'NO' then '🇳🇴'
-
when: 0
when 'DK' then '🇩🇰'
-
when: 0
when 'FI' then '🇫🇮'
-
when: 0
when 'JP' then '🇯🇵'
-
when: 0
when 'CN' then '🇨🇳'
-
when: 0
when 'IN' then '🇮🇳'
-
when: 0
when 'BR' then '🇧🇷'
-
when: 0
when 'MX' then '🇲🇽'
-
when: 0
when 'AR' then '🇦🇷'
-
when: 0
when 'CL' then '🇨🇱'
-
when: 0
when 'CO' then '🇨🇴'
-
when: 0
when 'PE' then '🇵🇪'
-
when: 0
when 'VE' then '🇻🇪'
-
when: 0
when 'RU' then '🇷🇺'
-
when: 0
when 'KR' then '🇰🇷'
-
when: 0
when 'TH' then '🇹🇭'
-
when: 0
when 'SG' then '🇸🇬'
-
when: 0
when 'MY' then '🇲🇾'
-
when: 0
when 'ID' then '🇮🇩'
-
when: 0
when 'PH' then '🇵🇭'
-
when: 0
when 'VN' then '🇻🇳'
-
when: 0
when 'ZA' then '🇿🇦'
-
when: 0
when 'EG' then '🇪🇬'
-
when: 0
when 'NG' then '🇳🇬'
-
when: 0
when 'KE' then '🇰🇪'
-
when: 0
when 'MA' then '🇲🇦'
-
when: 0
when 'TN' then '🇹🇳'
-
when: 0
when 'DZ' then '🇩🇿'
-
when: 0
when 'TR' then '🇹🇷'
-
when: 0
when 'SA' then '🇸🇦'
-
when: 0
when 'AE' then '🇦🇪'
-
when: 0
when 'IL' then '🇮🇱'
-
when: 0
when 'IR' then '🇮🇷'
-
when: 0
when 'IQ' then '🇮🇶'
-
when: 0
when 'PK' then '🇵🇰'
-
when: 0
when 'BD' then '🇧🇩'
-
when: 0
when 'LK' then '🇱🇰'
-
when: 0
when 'NP' then '🇳🇵'
-
when: 0
when 'BT' then '🇧🇹'
-
when: 0
when 'MV' then '🇲🇻'
-
when: 0
when 'AF' then '🇦🇫'
-
when: 0
when 'UZ' then '🇺🇿'
-
when: 0
when 'KZ' then '🇰🇿'
-
when: 0
when 'KG' then '🇰🇬'
-
when: 0
when 'TJ' then '🇹🇯'
-
when: 0
when 'TM' then '🇹🇲'
-
when: 0
when 'MN' then '🇲🇳'
-
when: 0
when 'MM' then '🇲🇲'
-
when: 0
when 'LA' then '🇱🇦'
-
when: 0
when 'KH' then '🇰🇭'
-
when: 0
when 'BN' then '🇧🇳'
-
when: 0
when 'TL' then '🇹🇱'
-
when: 0
when 'FJ' then '🇫🇯'
-
when: 0
when 'PG' then '🇵🇬'
-
when: 0
when 'SB' then '🇸🇧'
-
when: 0
when 'VU' then '🇻🇺'
-
when: 0
when 'NC' then '🇳🇨'
-
when: 0
when 'PF' then '🇵🇫'
-
when: 0
when 'WS' then '🇼🇸'
-
when: 0
when 'TO' then '🇹🇴'
-
when: 0
when 'KI' then '🇰🇮'
-
when: 0
when 'TV' then '🇹🇻'
-
when: 0
when 'NR' then '🇳🇷'
-
when: 0
when 'PW' then '🇵🇼'
-
when: 0
when 'FM' then '🇫🇲'
-
when: 0
when 'MH' then '🇲🇭'
-
when: 0
when 'CK' then '🇨🇰'
-
when: 0
when 'NU' then '🇳🇺'
-
when: 0
when 'TK' then '🇹🇰'
-
when: 0
when 'AS' then '🇦🇸'
-
when: 0
when 'GU' then '🇬🇺'
-
when: 0
when 'MP' then '🇲🇵'
-
when: 0
when 'VI' then '🇻🇮'
-
when: 0
when 'PR' then '🇵🇷'
-
when: 0
when 'DO' then '🇩🇴'
-
when: 0
when 'HT' then '🇭🇹'
-
when: 0
when 'CU' then '🇨🇺'
-
when: 0
when 'JM' then '🇯🇲'
-
when: 0
when 'BB' then '🇧🇧'
-
when: 0
when 'TT' then '🇹🇹'
-
when: 0
when 'GY' then '🇬🇾'
-
when: 0
when 'SR' then '🇸🇷'
-
when: 0
when 'GF' then '🇬🇫'
-
when: 0
when 'UY' then '🇺🇾'
-
when: 0
when 'PY' then '🇵🇾'
-
when: 0
when 'BO' then '🇧🇴'
-
when: 0
when 'EC' then '🇪🇨'
-
when: 0
when 'PA' then '🇵🇦'
-
when: 0
when 'CR' then '🇨🇷'
-
when: 0
when 'NI' then '🇳🇮'
-
when: 0
when 'HN' then '🇭🇳'
-
when: 0
when 'SV' then '🇸🇻'
-
when: 0
when 'GT' then '🇬🇹'
-
when: 0
when 'BZ' then '🇧🇿'
-
when: 0
when 'GY' then '🇬🇾'
-
when: 0
when 'SR' then '🇸🇷'
-
when: 0
when 'GF' then '🇬🇫'
-
when: 0
when 'UY' then '🇺🇾'
-
when: 0
when 'PY' then '🇵🇾'
-
when: 0
when 'BO' then '🇧🇴'
-
when: 0
when 'EC' then '🇪🇨'
-
when: 0
when 'PA' then '🇵🇦'
-
when: 0
when 'CR' then '🇨🇷'
-
when: 0
when 'NI' then '🇳🇮'
-
when: 0
when 'HN' then '🇭🇳'
-
when: 0
when 'SV' then '🇸🇻'
-
when: 0
when 'GT' then '🇬🇹'
-
else: 0
when 'BZ' then '🇧🇿'
-
else '🌍'
-
end
-
end
-
-
1
def country_name(country_code)
-
# Return full country name for country code
-
then: 0
else: 0
when: 0
case country_code&.upcase
-
when: 0
when 'US' then 'United States'
-
when: 0
when 'GB' then 'United Kingdom'
-
when: 0
when 'CA' then 'Canada'
-
when: 0
when 'AU' then 'Australia'
-
when: 0
when 'DE' then 'Germany'
-
when: 0
when 'FR' then 'France'
-
when: 0
when 'IT' then 'Italy'
-
when: 0
when 'ES' then 'Spain'
-
when: 0
when 'NL' then 'Netherlands'
-
when: 0
when 'SE' then 'Sweden'
-
when: 0
when 'NO' then 'Norway'
-
when: 0
when 'DK' then 'Denmark'
-
when: 0
when 'FI' then 'Finland'
-
when: 0
when 'JP' then 'Japan'
-
when: 0
when 'CN' then 'China'
-
when: 0
when 'IN' then 'India'
-
when: 0
when 'BR' then 'Brazil'
-
when: 0
when 'MX' then 'Mexico'
-
when: 0
when 'AR' then 'Argentina'
-
when: 0
when 'CL' then 'Chile'
-
when: 0
when 'CO' then 'Colombia'
-
when: 0
when 'PE' then 'Peru'
-
when: 0
when 'VE' then 'Venezuela'
-
when: 0
when 'RU' then 'Russia'
-
when: 0
when 'KR' then 'South Korea'
-
when: 0
when 'TH' then 'Thailand'
-
when: 0
when 'SG' then 'Singapore'
-
when: 0
when 'MY' then 'Malaysia'
-
when: 0
when 'ID' then 'Indonesia'
-
when: 0
when 'PH' then 'Philippines'
-
when: 0
when 'VN' then 'Vietnam'
-
when: 0
when 'ZA' then 'South Africa'
-
when: 0
when 'EG' then 'Egypt'
-
when: 0
when 'NG' then 'Nigeria'
-
when: 0
when 'KE' then 'Kenya'
-
when: 0
when 'MA' then 'Morocco'
-
when: 0
when 'TN' then 'Tunisia'
-
when: 0
when 'DZ' then 'Algeria'
-
when: 0
when 'TR' then 'Turkey'
-
when: 0
when 'SA' then 'Saudi Arabia'
-
when: 0
when 'AE' then 'United Arab Emirates'
-
when: 0
when 'IL' then 'Israel'
-
when: 0
when 'IR' then 'Iran'
-
when: 0
when 'IQ' then 'Iraq'
-
when: 0
when 'PK' then 'Pakistan'
-
when: 0
when 'BD' then 'Bangladesh'
-
when: 0
when 'LK' then 'Sri Lanka'
-
when: 0
when 'NP' then 'Nepal'
-
when: 0
when 'BT' then 'Bhutan'
-
when: 0
when 'MV' then 'Maldives'
-
when: 0
when 'AF' then 'Afghanistan'
-
when: 0
when 'UZ' then 'Uzbekistan'
-
when: 0
when 'KZ' then 'Kazakhstan'
-
when: 0
when 'KG' then 'Kyrgyzstan'
-
when: 0
when 'TJ' then 'Tajikistan'
-
when: 0
when 'TM' then 'Turkmenistan'
-
when: 0
when 'MN' then 'Mongolia'
-
when: 0
when 'MM' then 'Myanmar'
-
when: 0
when 'LA' then 'Laos'
-
when: 0
when 'KH' then 'Cambodia'
-
when: 0
when 'BN' then 'Brunei'
-
when: 0
when 'TL' then 'Timor-Leste'
-
when: 0
when 'FJ' then 'Fiji'
-
when: 0
when 'PG' then 'Papua New Guinea'
-
when: 0
when 'SB' then 'Solomon Islands'
-
when: 0
when 'VU' then 'Vanuatu'
-
when: 0
when 'NC' then 'New Caledonia'
-
when: 0
when 'PF' then 'French Polynesia'
-
when: 0
when 'WS' then 'Samoa'
-
when: 0
when 'TO' then 'Tonga'
-
when: 0
when 'KI' then 'Kiribati'
-
when: 0
when 'TV' then 'Tuvalu'
-
when: 0
when 'NR' then 'Nauru'
-
when: 0
when 'PW' then 'Palau'
-
when: 0
when 'FM' then 'Micronesia'
-
when: 0
when 'MH' then 'Marshall Islands'
-
when: 0
when 'CK' then 'Cook Islands'
-
when: 0
when 'NU' then 'Niue'
-
when: 0
when 'TK' then 'Tokelau'
-
when: 0
when 'AS' then 'American Samoa'
-
when: 0
when 'GU' then 'Guam'
-
when: 0
when 'MP' then 'Northern Mariana Islands'
-
when: 0
when 'VI' then 'U.S. Virgin Islands'
-
when: 0
when 'PR' then 'Puerto Rico'
-
when: 0
when 'DO' then 'Dominican Republic'
-
when: 0
when 'HT' then 'Haiti'
-
when: 0
when 'CU' then 'Cuba'
-
when: 0
when 'JM' then 'Jamaica'
-
when: 0
when 'BB' then 'Barbados'
-
when: 0
when 'TT' then 'Trinidad and Tobago'
-
when: 0
when 'GY' then 'Guyana'
-
when: 0
when 'SR' then 'Suriname'
-
when: 0
when 'GF' then 'French Guiana'
-
when: 0
when 'UY' then 'Uruguay'
-
when: 0
when 'PY' then 'Paraguay'
-
when: 0
when 'BO' then 'Bolivia'
-
when: 0
when 'EC' then 'Ecuador'
-
when: 0
when 'PA' then 'Panama'
-
when: 0
when 'CR' then 'Costa Rica'
-
when: 0
when 'NI' then 'Nicaragua'
-
when: 0
when 'HN' then 'Honduras'
-
when: 0
when 'SV' then 'El Salvador'
-
when: 0
when 'GT' then 'Guatemala'
-
else: 0
when 'BZ' then 'Belize'
-
else country_code
-
end
-
end
-
-
1
def device_icon(device)
-
then: 0
else: 0
when: 0
case device&.downcase
-
when: 0
when 'desktop' then '🖥️'
-
when: 0
when 'mobile' then '📱'
-
when: 0
when 'tablet' then '📱'
-
else: 0
when 'phone' then '📱'
-
else '💻'
-
end
-
end
-
-
1
def source_domain(referrer)
-
then: 0
else: 0
return 'Direct' if referrer.blank?
-
-
begin
-
uri = URI.parse(referrer)
-
domain = uri.host
-
-
when: 0
case domain
-
when: 0
when /google\./ then 'Google'
-
when: 0
when /bing\./ then 'Bing'
-
when: 0
when /yahoo\./ then 'Yahoo'
-
when: 0
when /duckduckgo\./ then 'DuckDuckGo'
-
when: 0
when /facebook\./ then 'Facebook'
-
when: 0
when /twitter\./ then 'Twitter'
-
when: 0
when /linkedin\./ then 'LinkedIn'
-
when: 0
when /instagram\./ then 'Instagram'
-
when: 0
when /youtube\./ then 'YouTube'
-
when: 0
when /reddit\./ then 'Reddit'
-
when: 0
when /pinterest\./ then 'Pinterest'
-
when: 0
when /tumblr\./ then 'Tumblr'
-
when: 0
when /medium\./ then 'Medium'
-
when: 0
when /dev\./ then 'Dev.to'
-
when: 0
when /hashnode\./ then 'Hashnode'
-
when: 0
when /hackernews\./ then 'Hacker News'
-
when: 0
when /github\./ then 'GitHub'
-
else: 0
when /stackoverflow\./ then 'Stack Overflow'
-
else domain
-
end
-
rescue
-
referrer
-
end
-
end
-
-
# Check if we're on an admin page
-
1
def admin_page?
-
controller_path.start_with?('admin/')
-
end
-
-
# Format large numbers
-
1
def format_number(num)
-
then: 0
else: 0
return '0' if num.nil? || num.zero?
-
-
then: 0
if num >= 1_000_000
-
else: 0
"#{(num / 1_000_000.0).round(1)}M"
-
then: 0
elsif num >= 1_000
-
"#{(num / 1_000.0).round(1)}K"
-
else: 0
else
-
num.to_s
-
end
-
end
-
-
# Format percentage
-
1
def format_percentage(num)
-
then: 0
else: 0
return '0%' if num.nil?
-
"#{num.round(1)}%"
-
end
-
-
# Get country flag emoji
-
1
def country_flag(country_code)
-
else: 0
then: 0
return '' unless country_code
-
-
# Convert country code to flag emoji
-
country_code.upcase.chars.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join
-
rescue
-
'🌍'
-
end
-
-
# Get device icon
-
1
def device_icon(device)
-
then: 0
else: 0
case device&.downcase
-
when: 0
when 'mobile'
-
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M7 2a2 2 0 00-2 2v12a2 2 0 002 2h6a2 2 0 002-2V4a2 2 0 00-2-2H7zm3 14a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>'
-
when: 0
when 'tablet'
-
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V4a2 2 0 00-2-2H6zm4 14a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>'
-
else: 0
else
-
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd"/></svg>'
-
end
-
end
-
-
# Get browser icon
-
1
def browser_icon(browser)
-
icons = {
-
'chrome' => '🌐',
-
'firefox' => '🦊',
-
'safari' => '🧭',
-
'edge' => '🔷',
-
'opera' => '🅾️'
-
}
-
-
then: 0
else: 0
icons[browser&.downcase] || '🌍'
-
end
-
end
-
-
-
-
-
-
-
-
-
1
module Api::V1::AiAgentsHelper
-
end
-
1
module Api
-
1
module V1
-
1
module DocsHelper
-
1
def method_badge_class(method)
-
case method.upcase
-
when: 0
when 'GET'
-
'bg-blue-500/10 text-blue-400'
-
when: 0
when 'POST'
-
'bg-green-500/10 text-green-400'
-
when: 0
when 'PATCH', 'PUT'
-
'bg-yellow-500/10 text-yellow-400'
-
when: 0
when 'DELETE'
-
'bg-red-500/10 text-red-400'
-
else: 0
else
-
'bg-gray-500/10 text-gray-400'
-
end
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
1
module AppearanceHelper
-
# Generate dynamic CSS based on appearance settings
-
1
def dynamic_appearance_css
-
color_scheme = SiteSetting.get('color_scheme', 'midnight')
-
primary_color = SiteSetting.get('primary_color', '#6366F1')
-
secondary_color = SiteSetting.get('secondary_color', '#8B5CF6')
-
heading_font = SiteSetting.get('heading_font', 'Inter')
-
body_font = SiteSetting.get('body_font', 'Inter')
-
paragraph_font = SiteSetting.get('paragraph_font', 'Inter')
-
-
# Color scheme variables
-
scheme_colors = color_scheme_colors(color_scheme)
-
-
# Determine if light theme
-
is_light_theme = color_scheme == 'amanecer'
-
-
# Set text colors based on theme with better contrast
-
then: 0
else: 0
text_primary = is_light_theme ? '#1a202c' : '#ffffff'
-
then: 0
else: 0
text_secondary = is_light_theme ? '#2d3748' : '#e8e8e8'
-
then: 0
else: 0
text_tertiary = is_light_theme ? '#4a5568' : '#a8a8a8'
-
then: 0
else: 0
text_muted = is_light_theme ? '#718096' : '#6b7280'
-
then: 0
else: 0
text_placeholder = is_light_theme ? '#a0aec0' : '#4b5563'
-
-
<<~CSS
-
<style id="dynamic-appearance">
-
:root {
-
/* Modern Admin Color System */
-
-
/* Backgrounds - Layered depth */
-
--bg-primary: #{scheme_colors[:bg_primary]};
-
--bg-secondary: #{scheme_colors[:bg_secondary]};
-
--bg-tertiary: #{scheme_colors[:bg_tertiary]};
-
--admin-bg-app: #{scheme_colors[:bg_primary]};
-
--admin-bg-primary: #{scheme_colors[:bg_secondary]};
-
--admin-bg-secondary: #{scheme_colors[:bg_tertiary]};
-
--admin-bg-tertiary: #{lighten_color(scheme_colors[:bg_tertiary], 3)};
-
--admin-bg-elevated: #{lighten_color(scheme_colors[:bg_tertiary], 5)};
-
-
/* Borders - Subtle hierarchy */
-
--border-color: #{scheme_colors[:border_color]};
-
--admin-border-subtle: #{scheme_colors[:border_color]};
-
--admin-border: #{lighten_color(scheme_colors[:border_color], 5)};
-
--admin-border-strong: #{lighten_color(scheme_colors[:border_color], 10)};
-
-
/* Text - High contrast */
-
--text-primary: #{text_primary};
-
--text-secondary: #{text_secondary};
-
--text-muted: #{text_muted};
-
--admin-text-primary: #{text_primary};
-
--admin-text-secondary: #{text_secondary};
-
--admin-text-tertiary: #{text_tertiary};
-
--admin-text-muted: #{text_muted};
-
--admin-text-placeholder: #{text_placeholder};
-
-
/* Brand Colors - Vibrant accents */
-
--color-primary: #{primary_color};
-
--color-secondary: #{secondary_color};
-
--admin-primary: #{primary_color};
-
--admin-primary-hover: #{darken_color(primary_color, 8)};
-
--admin-primary-light: #{hex_to_rgba(primary_color, 0.1)};
-
--admin-secondary: #{secondary_color};
-
--admin-secondary-hover: #{darken_color(secondary_color, 8)};
-
--admin-secondary-light: #{hex_to_rgba(secondary_color, 0.1)};
-
-
/* Status Colors */
-
--admin-success: #10b981;
-
--admin-success-light: rgba(16, 185, 129, 0.1);
-
--admin-success-border: rgba(16, 185, 129, 0.2);
-
-
--admin-warning: #f59e0b;
-
--admin-warning-light: rgba(245, 158, 11, 0.1);
-
--admin-warning-border: rgba(245, 158, 11, 0.2);
-
-
--admin-error: #ef4444;
-
--admin-error-light: rgba(239, 68, 68, 0.1);
-
--admin-error-border: rgba(239, 68, 68, 0.2);
-
-
--admin-info: #3b82f6;
-
--admin-info-light: rgba(59, 130, 246, 0.1);
-
--admin-info-border: rgba(59, 130, 246, 0.2);
-
-
/* Typography */
-
--font-heading: #{heading_font};
-
--font-body: #{body_font};
-
--font-paragraph: #{paragraph_font};
-
}
-
-
/* Apply brand colors */
-
.bg-indigo-600, .bg-primary {
-
background-color: var(--color-primary) !important;
-
}
-
-
.text-indigo-600, .text-indigo-400 {
-
color: var(--color-primary) !important;
-
}
-
-
.border-indigo-500, .focus\\:ring-indigo-500:focus {
-
border-color: var(--color-primary) !important;
-
}
-
-
.ring-indigo-500 {
-
--tw-ring-color: var(--color-primary) !important;
-
}
-
-
/* Hover states */
-
.hover\\:bg-indigo-700:hover {
-
background-color: #{darken_color(primary_color, 10)} !important;
-
}
-
-
/* Secondary color */
-
.bg-purple-600 {
-
background-color: var(--color-secondary) !important;
-
}
-
-
/* Text color overrides for consistency */
-
.text-white {
-
color: var(--text-primary) !important;
-
}
-
-
.text-gray-300, .text-gray-400 {
-
color: var(--text-secondary) !important;
-
}
-
-
.text-gray-500, .text-gray-600 {
-
color: var(--text-muted) !important;
-
}
-
-
/* Typography */
-
h1, h2, h3, h4, h5, h6 {
-
font-family: var(--font-heading), sans-serif !important;
-
}
-
-
body, button, input, select, textarea {
-
font-family: var(--font-body), sans-serif !important;
-
}
-
-
p, .paragraph {
-
font-family: var(--font-paragraph), sans-serif !important;
-
}
-
-
/* Color scheme background */
-
.bg-\\[\\#0a0a0a\\], .bg-\\[\\#111111\\] {
-
background-color: var(--bg-primary) !important;
-
}
-
-
.bg-\\[\\#1a1a1a\\] {
-
background-color: var(--bg-secondary) !important;
-
}
-
-
.border-\\[\\#2a2a2a\\] {
-
border-color: var(--border-color) !important;
-
}
-
-
/* Light theme text colors */
-
then: 0
#{color_scheme == 'amanecer' ? '
-
body, .text-white {
-
color: #1a202c !important;
-
}
-
-
.text-gray-300, .text-gray-400 {
-
color: #4a5568 !important;
-
}
-
-
.text-gray-500, .text-gray-600 {
-
color: #718096 !important;
-
}
-
-
h1, h2, h3, h4, h5, h6 {
-
color: #1a202c !important;
-
}
-
-
input, select, textarea {
-
color: #1a202c !important;
-
background-color: #ffffff !important;
-
}
-
-
input::placeholder, textarea::placeholder {
-
color: #a0aec0 !important;
-
}
-
-
/* Update specific dark text classes for light theme */
-
.text-emerald-400, .text-green-400 {
-
color: #10b981 !important;
-
}
-
-
.text-red-400 {
-
color: #ef4444 !important;
-
}
-
-
.text-blue-400, .text-indigo-400 {
-
color: #6366f1 !important;
-
}
-
-
.text-yellow-400 {
-
color: #f59e0b !important;
-
}
-
-
/* Sidebar text */
-
nav a, nav span {
-
color: #4a5568 !important;
-
}
-
-
nav a:hover {
-
color: #1a202c !important;
-
}
-
-
/* Top bar */
-
header {
-
border-bottom-color: #e2e8f0 !important;
-
else: 0
}
-
' : ''}
-
</style>
-
CSS
-
.html_safe
-
end
-
-
# Get white label settings
-
1
def admin_app_name
-
SiteSetting.get('admin_app_name', 'RailsPress')
-
end
-
-
1
def admin_logo_url
-
SiteSetting.get('admin_logo_url', '')
-
end
-
-
1
def admin_favicon_url
-
SiteSetting.get('admin_favicon_url', '')
-
end
-
-
1
def admin_footer_text
-
SiteSetting.get('admin_footer_text', 'Powered by RailsPress')
-
end
-
-
1
def hide_branding?
-
SiteSetting.get('hide_branding', false) == true || SiteSetting.get('hide_branding', false) == '1'
-
end
-
-
1
private
-
-
1
def color_scheme_colors(scheme)
-
case scheme
-
when: 0
when 'midnight' # New default - Modern, sophisticated
-
{
-
bg_primary: '#0f0f0f',
-
bg_secondary: '#141414',
-
bg_tertiary: '#1a1a1a',
-
border_color: '#2f2f2f'
-
}
-
when: 0
when 'vallarta' # Blue ocean theme
-
{
-
bg_primary: '#0a1628',
-
bg_secondary: '#0f1e3a',
-
bg_tertiary: '#1a2947',
-
border_color: '#2a3f5f'
-
}
-
when: 0
when 'amanecer' # Light theme
-
{
-
bg_primary: '#ffffff',
-
bg_secondary: '#f8f9fa',
-
bg_tertiary: '#f1f3f5',
-
border_color: '#e9ecef'
-
}
-
when: 0
when 'onyx' # Pure black
-
{
-
bg_primary: '#000000',
-
bg_secondary: '#0a0a0a',
-
bg_tertiary: '#111111',
-
border_color: '#1a1a1a'
-
}
-
when: 0
when 'slate' # Cool gray
-
{
-
bg_primary: '#0f172a',
-
bg_secondary: '#1e293b',
-
bg_tertiary: '#334155',
-
border_color: '#475569'
-
}
-
else: 0
else # midnight (default)
-
{
-
bg_primary: '#0f0f0f',
-
bg_secondary: '#141414',
-
bg_tertiary: '#1a1a1a',
-
border_color: '#2f2f2f'
-
}
-
end
-
end
-
-
1
def darken_color(hex, percent)
-
hex = hex.delete('#')
-
rgb = hex.scan(/../).map { |color| color.hex }
-
rgb = rgb.map { |color| [(color * (100 - percent) / 100).to_i, 0].max }
-
"#%02x%02x%02x" % rgb
-
end
-
-
1
def lighten_color(hex, percent)
-
hex = hex.delete('#')
-
rgb = hex.scan(/../).map { |color| color.hex }
-
rgb = rgb.map { |color| [color + (255 - color) * percent / 100, 255].min.to_i }
-
"#%02x%02x%02x" % rgb
-
end
-
-
1
def hex_to_rgba(hex, alpha = 1.0)
-
hex = hex.delete('#')
-
rgb = hex.scan(/../).map { |color| color.hex }
-
"rgba(#{rgb[0]}, #{rgb[1]}, #{rgb[2]}, #{alpha})"
-
end
-
end
-
-
1
module ApplicationHelper
-
end
-
1
module ConsentHelper
-
# Render consent banner HTML
-
1
def render_consent_banner
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return '' unless consent_config
-
-
# Get user's region (simplified for Liquid templates)
-
region = get_user_region
-
user_consent = get_user_consent_data
-
-
consent_config.generate_banner_html(region, user_consent)
-
end
-
-
# Render consent banner CSS
-
1
def render_consent_css
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return '' unless consent_config
-
-
consent_config.generate_banner_css
-
end
-
-
# Render consent-aware pixel code
-
1
def render_pixel_with_consent(pixel)
-
then: 0
else: 0
else: 0
then: 0
return '' unless pixel&.active?
-
-
# Get consent configuration
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return pixel.render_code unless consent_config
-
-
# Check if pixel requires consent
-
required_consent = consent_config.get_consent_categories_for_pixel(pixel.pixel_type)
-
-
if required_consent.any?
-
then: 0
# Pixel requires consent - wrap in consent-aware code
-
consent_categories = required_consent.join(',')
-
-
<<~HTML
-
<div data-pixel-type="#{pixel.pixel_type}" data-consent-categories="#{consent_categories}" class="consent-pixel" style="display: none;">
-
#{pixel.render_code}
-
</div>
-
HTML
-
else
-
else: 0
# Pixel doesn't require consent - render normally
-
pixel.render_code
-
end
-
end
-
-
# Render all pixels with consent awareness
-
1
def render_all_pixels_with_consent(position = nil)
-
pixels = Pixel.active
-
then: 0
else: 0
pixels = pixels.by_position(position) if position
-
-
pixels.map { |pixel| render_pixel_with_consent(pixel) }.join.html_safe
-
end
-
-
# Check if user has given consent for a specific category
-
1
def user_has_consent?(category)
-
else: 0
then: 0
return false unless user_signed_in?
-
-
then: 0
else: 0
current_user.user_consents.find_by(consent_type: category)&.granted? || false
-
end
-
-
# Check if user has given consent for a pixel type
-
1
def user_has_pixel_consent?(pixel_type)
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return true unless consent_config # If no consent config, allow all
-
-
required_categories = consent_config.get_consent_categories_for_pixel(pixel_type)
-
then: 0
else: 0
return true if required_categories.empty? # No consent required
-
-
required_categories.all? { |category| user_has_consent?(category) }
-
end
-
-
# Get consent status for current user
-
1
def user_consent_status
-
else: 0
then: 0
return {} unless user_signed_in?
-
-
current_user.user_consents.index_by(&:consent_type).transform_values do |consent|
-
{
-
granted: consent.granted?,
-
granted_at: consent.granted_at,
-
withdrawn_at: consent.withdrawn_at
-
}
-
end
-
end
-
-
# Render consent management link
-
1
def consent_management_link(text = 'Manage Cookie Preferences', css_class = '')
-
else: 0
then: 0
return '' unless ConsentConfiguration.active.exists?
-
-
link_to text, '#',
-
class: "consent-management-link #{css_class}",
-
onclick: 'ConsentManager.showPreferencesModal(); return false;'
-
end
-
-
# Render consent status indicator
-
1
def consent_status_indicator(category)
-
else: 0
then: 0
return '' unless user_signed_in?
-
-
consent = current_user.user_consents.find_by(consent_type: category)
-
else: 0
then: 0
return '' unless consent
-
-
then: 0
else: 0
status_class = consent.granted? ? 'consent-granted' : 'consent-withdrawn'
-
then: 0
else: 0
status_text = consent.granted? ? 'Granted' : 'Withdrawn'
-
-
content_tag :span, status_text, class: "consent-status #{status_class}"
-
end
-
-
# Render consent banner for specific region
-
1
def render_region_specific_banner(region)
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return '' unless consent_config
-
-
# Check if banner should be shown for this region
-
else: 0
then: 0
return '' unless consent_config.should_show_banner?(region)
-
-
consent_config.generate_banner_html(region)
-
end
-
-
# Get consent configuration for JavaScript
-
1
def consent_config_json
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return '{}' unless consent_config
-
-
{
-
consent_categories: consent_config.consent_categories_with_defaults,
-
banner_settings: consent_config.banner_settings_with_defaults,
-
geolocation_settings: consent_config.geolocation_settings_with_defaults,
-
pixel_consent_mapping: consent_config.pixel_consent_mapping_with_defaults,
-
version: consent_config.version || '1.0'
-
}.to_json
-
end
-
-
# Render consent banner initialization script
-
1
def consent_banner_script
-
else: 0
then: 0
return '' unless ConsentConfiguration.active.exists?
-
-
<<~HTML
-
<script>
-
document.addEventListener('DOMContentLoaded', function() {
-
// Initialize consent manager with configuration
-
if (typeof ConsentManager !== 'undefined') {
-
window.consentManager = new ConsentManager({
-
config: #{consent_config_json},
-
debug: #{Rails.env.development?}
-
});
-
}
-
});
-
</script>
-
HTML
-
end
-
-
# Render consent banner CSS and HTML
-
1
def consent_banner_assets
-
else: 0
then: 0
return '' unless ConsentConfiguration.active.exists?
-
-
css = render_consent_css
-
html = render_consent_banner
-
script = consent_banner_script
-
-
<<~HTML
-
<style>
-
#{css}
-
</style>
-
#{html}
-
#{script}
-
HTML
-
end
-
-
# Check if consent banner should be shown
-
1
def should_show_consent_banner?
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return false unless consent_config
-
-
# Check if user has already given consent
-
then: 0
else: 0
return false if user_signed_in? && current_user.user_consents.granted.exists?
-
-
# Check if banner is enabled
-
consent_config.banner_settings_with_defaults['enabled']
-
end
-
-
# Render consent banner only if needed
-
1
def render_consent_banner_if_needed
-
else: 0
then: 0
return '' unless should_show_consent_banner?
-
-
consent_banner_assets
-
end
-
-
# Get user's consent data for JavaScript
-
1
def user_consent_json
-
else: 0
then: 0
return '{}' unless user_signed_in?
-
-
user_consent_status.to_json
-
end
-
-
# Render user consent data script
-
1
def user_consent_script
-
else: 0
then: 0
return '' unless user_signed_in?
-
-
<<~HTML
-
<script>
-
window.userConsentData = #{user_consent_json};
-
</script>
-
HTML
-
end
-
-
# Render consent-aware pixel loading script
-
1
def consent_pixel_script
-
else: 0
then: 0
return '' unless ConsentConfiguration.active.exists?
-
-
<<~HTML
-
<script>
-
// Override pixel loading to respect consent
-
document.addEventListener('DOMContentLoaded', function() {
-
// Find all consent-aware pixels
-
const consentPixels = document.querySelectorAll('[data-pixel-type][data-consent-categories]');
-
-
consentPixels.forEach(function(pixel) {
-
const pixelType = pixel.dataset.pixelType;
-
const requiredCategories = pixel.dataset.consentCategories.split(',');
-
-
// Check if user has required consent
-
let hasConsent = true;
-
if (window.userConsentData) {
-
hasConsent = requiredCategories.every(function(category) {
-
return window.userConsentData[category] && window.userConsentData[category].granted;
-
});
-
}
-
-
if (hasConsent) {
-
// Load the pixel
-
pixel.style.display = '';
-
pixel.classList.remove('consent-disabled');
-
} else {
-
// Keep pixel hidden
-
pixel.style.display = 'none';
-
pixel.classList.add('consent-disabled');
-
}
-
});
-
});
-
</script>
-
HTML
-
end
-
-
1
private
-
-
1
def get_user_region
-
# Simplified region detection for Liquid templates
-
# In a real implementation, this would use the same logic as the API
-
request.remote_ip || 'unknown'
-
end
-
-
1
def get_user_consent_data
-
else: 0
then: 0
return [] unless user_signed_in?
-
-
current_user.user_consents.map do |consent|
-
{
-
consent_type: consent.consent_type,
-
granted: consent.granted?
-
}
-
end
-
end
-
end
-
1
module EditorHelper
-
# Main method to render the content editor based on user preference
-
1
def render_content_editor(form, field_name, content: nil, options: {})
-
# Get content from form object if not provided
-
then: 0
else: 0
content = form.object.send(field_name) if content.nil? && form.object.respond_to?(field_name)
-
-
# Get user's preferred editor or default to blocknote
-
then: 0
else: 0
editor_type = current_user&.preferred_editor || 'blocknote'
-
placeholder = options[:placeholder] || 'Start writing...'
-
-
# Render the reusable content editor partial
-
render partial: 'shared/content_editor', locals: {
-
form: form,
-
content: content,
-
field_name: field_name,
-
placeholder: placeholder,
-
editor_type: editor_type
-
}
-
end
-
-
# Editor preference options for settings
-
1
def editor_preference_options
-
[
-
['BlockNote - Modern Block Editor (Default)', 'blocknote'],
-
['Trix - ActionText Rich Text', 'trix'],
-
['CKEditor - Classic WYSIWYG', 'ckeditor'],
-
['Editor.js - JSON-based Editor', 'editorjs']
-
]
-
end
-
-
# Get display name for editor type
-
1
def editor_display_name(editor_type)
-
case editor_type
-
when: 0
when 'blocknote'
-
'BlockNote'
-
when: 0
when 'trix'
-
'Trix (ActionText)'
-
when: 0
when 'ckeditor'
-
'CKEditor'
-
when: 0
when 'editorjs'
-
'Editor.js'
-
else: 0
else
-
editor_type.titleize
-
end
-
end
-
-
# Check if user has a specific editor preference set
-
1
def user_has_editor_preference?
-
then: 0
else: 0
current_user&.editor_preference.present?
-
end
-
-
# Get editor icon for UI
-
1
def editor_icon(editor_type)
-
case editor_type
-
when: 0
when 'blocknote'
-
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
-
</svg>'.html_safe
-
when: 0
when 'trix'
-
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
-
</svg>'.html_safe
-
when: 0
when 'ckeditor'
-
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
-
</svg>'.html_safe
-
when: 0
when 'editorjs'
-
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
-
</svg>'.html_safe
-
else: 0
else
-
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
-
</svg>'.html_safe
-
end
-
end
-
end
-
1
module ImageOptimizationHelper
-
# Generate picture element with multiple formats for optimal browser support
-
1
def optimized_image_tag(upload, options = {})
-
else: 0
then: 0
return image_tag(upload.url, options) unless upload.image?
-
-
# Build picture element with fallbacks
-
content_tag :picture do
-
# AVIF variant (best compression)
-
then: 0
else: 0
if upload.has_variant?('avif')
-
concat content_tag(:source, '',
-
srcset: upload.avif_url,
-
type: 'image/avif'
-
)
-
end
-
-
# WebP variant (good compression, wide support)
-
then: 0
else: 0
if upload.has_variant?('webp')
-
concat content_tag(:source, '',
-
srcset: upload.webp_url,
-
type: 'image/webp'
-
)
-
end
-
-
# Original image as fallback
-
concat image_tag(upload.url, options)
-
end
-
end
-
-
# Generate responsive image with multiple sizes
-
1
def responsive_image_tag(upload, options = {})
-
else: 0
then: 0
return optimized_image_tag(upload, options) unless upload.image?
-
-
sizes = options.delete(:sizes) || '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw'
-
-
content_tag :picture do
-
# AVIF variants for different sizes
-
then: 0
else: 0
if upload.has_variant?('avif')
-
concat content_tag(:source, '',
-
srcset: generate_srcset(upload, 'avif'),
-
sizes: sizes,
-
type: 'image/avif'
-
)
-
end
-
-
# WebP variants for different sizes
-
then: 0
else: 0
if upload.has_variant?('webp')
-
concat content_tag(:source, '',
-
srcset: generate_srcset(upload, 'webp'),
-
sizes: sizes,
-
type: 'image/webp'
-
)
-
end
-
-
# Original image variants for different sizes
-
concat image_tag(upload.url,
-
options.merge(
-
srcset: generate_srcset(upload, 'original'),
-
sizes: sizes
-
)
-
)
-
end
-
end
-
-
# Generate CSS for background image with format fallbacks
-
1
def optimized_background_image_css(upload)
-
else: 0
then: 0
return "background-image: url('#{upload.url}');" unless upload.image?
-
-
css_parts = []
-
-
# Add AVIF variant if available
-
then: 0
else: 0
if upload.has_variant?('avif')
-
css_parts << "background-image: url('#{upload.avif_url}');"
-
end
-
-
# Add WebP variant if available
-
then: 0
else: 0
if upload.has_variant?('webp')
-
css_parts << "background-image: url('#{upload.webp_url}');"
-
end
-
-
# Add original as fallback
-
css_parts << "background-image: url('#{upload.url}');"
-
-
css_parts.join(' ')
-
end
-
-
# Check if browser supports modern image formats
-
1
def supports_avif?
-
# This would typically be detected via JavaScript
-
# For now, we'll assume modern browsers support it
-
true
-
end
-
-
1
def supports_webp?
-
# This would typically be detected via JavaScript
-
# For now, we'll assume most browsers support it
-
true
-
end
-
-
1
private
-
-
1
def generate_srcset(upload, format)
-
# This would generate different sizes of the image
-
# For now, we'll return the single variant URL
-
case format
-
when: 0
when 'avif'
-
upload.avif_url
-
when: 0
when 'webp'
-
upload.webp_url
-
else: 0
else
-
upload.url
-
end
-
end
-
end
-
1
module MonacoHelper
-
# Monaco Editor theme mappings based on admin themes
-
ADMIN_THEME_MONACO_MAPPINGS = {
-
1
'onyx' => 'vs-dark',
-
'vallarta' => 'vs-dark-blue',
-
'amanecer' => 'vs',
-
'default' => 'vs'
-
}.freeze
-
-
# Get the appropriate Monaco theme based on user preference and admin theme
-
1
def monaco_theme_for_user(user = current_user, admin_theme = 'default')
-
then: 0
else: 0
return 'vs' if user.nil?
-
-
case user.preferred_monaco_theme
-
when 'auto'
-
when: 0
# Auto-detect based on admin theme
-
ADMIN_THEME_MONACO_MAPPINGS[admin_theme.downcase] || 'vs'
-
when: 0
when 'dark'
-
'vs-dark'
-
when: 0
when 'light'
-
'vs'
-
when: 0
when 'blue'
-
'vs-dark-blue'
-
else: 0
else
-
'vs'
-
end
-
end
-
-
# Get Monaco theme options for dropdown
-
1
def monaco_theme_options
-
[
-
['Auto (Follow Admin Theme)', 'auto'],
-
['Dark', 'dark'],
-
['Light', 'light'],
-
['Blue', 'blue']
-
]
-
end
-
-
# Get current admin theme (this would need to be implemented based on your admin theme system)
-
1
def current_admin_theme
-
# This should return the current admin theme name
-
# For now, we'll use a default or get it from session/cookie
-
session[:admin_theme] || 'default'
-
end
-
-
# Generate Monaco Editor configuration
-
1
def monaco_editor_config(options = {})
-
theme = monaco_theme_for_user(current_user, current_admin_theme)
-
-
default_config = {
-
theme: theme,
-
automaticLayout: true,
-
fontSize: 14,
-
lineNumbers: 'on',
-
minimap: { enabled: true },
-
scrollBeyondLastLine: false,
-
wordWrap: 'on',
-
tabSize: 2,
-
insertSpaces: true,
-
formatOnPaste: true,
-
formatOnType: true,
-
fixedOverflowWidgets: true,
-
renderLineHighlight: 'line',
-
cursorStyle: 'line',
-
cursorBlinking: 'blink'
-
}
-
-
default_config.merge(options)
-
end
-
end
-
-
-
-
-
# frozen_string_literal: true
-
-
1
module PixelsHelper
-
# Render all active pixels for a specific position
-
#
-
# @param position [Symbol] The position (:head, :body_start, :body_end)
-
# @return [String] Rendered HTML
-
1
def render_pixels(position)
-
then: 0
else: 0
return '' if admin_page?
-
-
pixels = Pixel.active.by_position(position).ordered
-
then: 0
else: 0
return '' if pixels.empty?
-
-
output = []
-
output << "<!-- RailsPress Tracking Pixels - #{position.to_s.titleize} -->"
-
-
pixels.each do |pixel|
-
else: 0
then: 0
next unless pixel.configured?
-
-
output << "<!-- #{pixel.name} (#{pixel.pixel_type.titleize}) -->"
-
output << pixel.render_code
-
end
-
-
output << "<!-- End RailsPress Tracking Pixels -->"
-
output.join("\n").html_safe
-
end
-
-
# Check if we're on an admin page
-
1
def admin_page?
-
controller_path.start_with?('admin/')
-
end
-
-
# Get pixel statistics
-
1
def pixel_stats
-
{
-
total: Pixel.count,
-
active: Pixel.active.count,
-
by_position: Pixel.active.group(:position).count
-
}
-
end
-
end
-
-
-
-
-
-
-
-
-
# frozen_string_literal: true
-
-
1
module PluginBlocksHelper
-
# Render plugin blocks for a specific location and position
-
#
-
# @param location [Symbol] The location (e.g., :post, :page)
-
# @param position [Symbol] The position (e.g., :sidebar, :main)
-
# @param context [Hash] Context to pass to blocks
-
# @return [String] Rendered HTML
-
1
def render_plugin_blocks(location, position: :sidebar, **context)
-
# Ensure we always have a hash to work with
-
else: 0
then: 0
context = {} unless context.is_a?(Hash)
-
-
full_context = context.merge(
-
current_user: current_user,
-
controller: controller,
-
action_name: action_name
-
)
-
-
result = Railspress::PluginBlocks.render_all(
-
location,
-
position: position,
-
context: full_context,
-
view_context: self
-
)
-
-
# Ensure we return a safe string
-
then: 0
else: 0
result.is_a?(String) ? result.html_safe : ''
-
rescue => e
-
Rails.logger.error("Error rendering plugin blocks: #{e.message}")
-
Rails.logger.error(e.backtrace.join("\n"))
-
-
then: 0
if Rails.env.development?
-
content_tag(:div, class: 'p-4 bg-red-500/10 border border-red-500/20 rounded text-red-400 text-sm') do
-
"Error rendering plugin blocks: #{e.message}"
-
end
-
else: 0
else
-
''
-
end
-
end
-
-
# Check if there are any blocks for a location/position
-
#
-
# @param location [Symbol] The location
-
# @param position [Symbol] The position
-
# @param context [Hash] Context for can_render checks
-
# @return [Boolean] True if blocks exist
-
1
def plugin_blocks_present?(location, position: :sidebar, **context)
-
full_context = context.merge(
-
current_user: current_user,
-
controller: controller,
-
action_name: action_name
-
)
-
-
Railspress::PluginBlocks.for_location(
-
location,
-
position: position,
-
context: full_context
-
).any?
-
end
-
-
# Render a single plugin block
-
#
-
# @param key [Symbol] The block key
-
# @param context [Hash] Context to pass to the block
-
# @return [String] Rendered HTML
-
1
def render_plugin_block(key, **context)
-
full_context = context.merge(
-
current_user: current_user,
-
controller: controller,
-
action_name: action_name
-
)
-
-
Railspress::PluginBlocks.render(
-
key,
-
context: full_context,
-
view_context: self
-
)
-
end
-
end
-
-
1
module PluginSettingsHelper
-
# Render plugin settings form from schema
-
1
def render_plugin_settings_form(plugin, settings_values = {})
-
else: 0
then: 0
return content_tag(:div, "No plugin instance provided", class: 'text-red-500') unless plugin
-
else: 0
then: 0
return content_tag(:div, "Plugin has no settings", class: 'text-gray-500') unless plugin.has_settings?
-
-
schema = plugin.settings_schema
-
-
# Since the schema is flat, we'll render all settings in one group
-
# In the future, we could enhance the DSL to support proper grouping
-
content_tag(:div, class: 'space-y-8') do
-
render_settings_group('General', schema, settings_values, plugin.name)
-
end
-
end
-
-
# Render a single settings group
-
1
def render_settings_group(group_name, group_settings, settings_values, plugin_name)
-
# Build the content manually instead of using content_tag with concat
-
fields_html = group_settings.map do |setting|
-
render_settings_field(setting, settings_values[setting[:key]], plugin_name)
-
end.join.html_safe
-
-
content_tag(:div, class: 'bg-white dark:bg-gray-800 rounded-lg shadow-md p-6') do
-
content_tag(:h2, group_name, class: 'text-xl font-semibold text-gray-900 dark:text-white mb-6') +
-
content_tag(:div, fields_html, class: 'space-y-6')
-
end
-
end
-
-
# Render a single settings field
-
1
def render_settings_field(field, value, plugin_name)
-
value ||= field[:default]
-
field_name = "settings[#{field[:key]}]"
-
field_id = "#{plugin_name.underscore}_#{field[:key]}"
-
-
content_tag(:div, class: 'space-y-2') do
-
case field[:type]
-
when: 0
when 'text', 'email', 'url', 'number'
-
render_text_field(field, value, field_name, field_id)
-
when: 0
when 'textarea'
-
render_textarea_field(field, value, field_name, field_id)
-
when: 0
when 'checkbox'
-
render_checkbox_field(field, value, field_name, field_id)
-
when: 0
when 'select'
-
render_select_field(field, value, field_name, field_id)
-
when: 0
when 'radio'
-
render_radio_field(field, value, field_name, field_id)
-
when: 0
when 'color'
-
render_color_field(field, value, field_name, field_id)
-
when: 0
when 'wysiwyg'
-
render_wysiwyg_field(field, value, field_name, field_id)
-
when: 0
when 'code'
-
render_code_field(field, value, field_name, field_id)
-
else: 0
else
-
render_text_field(field, value, field_name, field_id)
-
end
-
end
-
end
-
-
1
private
-
-
1
def render_text_field(field, value, field_name, field_id)
-
label_html = label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
-
input_html = text_field_tag(field_name, value,
-
id: field_id,
-
type: field[:type],
-
required: field[:required],
-
placeholder: field[:placeholder],
-
class: 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white'
-
)
-
then: 0
else: 0
description_html = field[:description] ? content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') : ''
-
-
content_tag(:div, label_html + input_html + description_html)
-
end
-
-
1
def render_textarea_field(field, value, field_name, field_id)
-
label_html = label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
-
textarea_html = text_area_tag(field_name, value,
-
id: field_id,
-
rows: field[:rows] || 4,
-
required: field[:required],
-
placeholder: field[:placeholder],
-
class: 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white'
-
)
-
then: 0
else: 0
description_html = field[:description] ? content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') : ''
-
-
content_tag(:div, label_html + textarea_html + description_html)
-
end
-
-
1
def render_checkbox_field(field, value, field_name, field_id)
-
checkbox_html = check_box_tag(field_name, '1', value.to_s == '1' || value == true,
-
id: field_id,
-
class: 'w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 mt-1'
-
)
-
label_html = label_tag(field_id, field[:label], class: 'text-sm font-medium text-gray-700 dark:text-gray-300')
-
then: 0
else: 0
description_html = field[:description] ? content_tag(:p, field[:description], class: 'text-sm text-gray-500 dark:text-gray-400') : ''
-
-
content_tag(:div, class: 'flex items-start') do
-
checkbox_html + content_tag(:div, label_html + description_html, class: 'ml-3')
-
end
-
end
-
-
1
def render_select_field(field, value, field_name, field_id)
-
label_html = label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
-
select_html = select_tag(field_name, options_for_select(field[:options], value),
-
id: field_id,
-
required: field[:required],
-
class: 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white'
-
)
-
then: 0
else: 0
description_html = field[:description] ? content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') : ''
-
-
content_tag(:div, label_html + select_html + description_html)
-
end
-
-
1
def render_radio_field(field, value, field_name, field_id)
-
content_tag(:div) do
-
concat label_tag(nil, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3')
-
concat content_tag(:div, class: 'space-y-2') do
-
field[:options].map.with_index do |(choice_label, choice_value), index|
-
content_tag(:div, class: 'flex items-center') do
-
concat radio_button_tag(field_name, choice_value, value == choice_value,
-
id: "#{field_id}_#{index}",
-
class: 'w-4 h-4 text-indigo-600 border-gray-300 focus:ring-indigo-500'
-
)
-
concat label_tag("#{field_id}_#{index}", choice_label, class: 'ml-2 text-sm text-gray-700 dark:text-gray-300')
-
end
-
end.join.html_safe
-
end
-
then: 0
else: 0
concat content_tag(:p, field[:description], class: 'mt-2 text-sm text-gray-500 dark:text-gray-400') if field[:description]
-
end
-
end
-
-
1
def render_color_field(field, value, field_name, field_id)
-
content_tag(:div) do
-
concat label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
-
concat content_tag(:div, class: 'flex items-center gap-3') do
-
concat color_field_tag(field_name, value || '#000000',
-
id: field_id,
-
class: 'h-10 w-20 rounded border border-gray-300'
-
)
-
concat text_field_tag("#{field_name}_text", value || '#000000',
-
class: 'flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white font-mono text-sm',
-
onchange: "document.getElementById('#{field_id}').value = this.value"
-
)
-
end
-
then: 0
else: 0
concat content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') if field[:description]
-
end
-
end
-
-
1
def render_wysiwyg_field(field, value, field_name, field_id)
-
content_tag(:div) do
-
concat label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
-
concat text_area_tag(field_name, value,
-
id: field_id,
-
rows: 8,
-
class: 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white',
-
data: { controller: 'trix' }
-
)
-
then: 0
else: 0
concat content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') if field[:description]
-
end
-
end
-
-
1
def render_code_field(field, value, field_name, field_id)
-
content_tag(:div) do
-
concat label_tag(field_id, field[:label], class: 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2')
-
concat text_area_tag(field_name, value,
-
id: field_id,
-
rows: 12,
-
class: 'w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-indigo-500 dark:bg-gray-900 dark:text-green-400 font-mono text-sm',
-
spellcheck: 'false'
-
)
-
then: 0
else: 0
concat content_tag(:p, field[:description], class: 'mt-1 text-sm text-gray-500 dark:text-gray-400') if field[:description]
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
1
module SeoHelper
-
# Render complete SEO meta tags for a post or page
-
1
def render_seo_tags(resource)
-
else: 0
then: 0
return '' unless resource.respond_to?(:seo_title)
-
-
tags = []
-
-
# Basic meta tags
-
then: 0
else: 0
tags << tag.meta(name: 'description', content: resource.seo_description) if resource.seo_description
-
then: 0
else: 0
tags << tag.meta(name: 'keywords', content: resource.meta_keywords) if resource.meta_keywords
-
tags << tag.meta(name: 'robots', content: resource.seo_robots)
-
tags << tag.link(rel: 'canonical', href: resource.seo_canonical_url)
-
-
# Open Graph tags
-
tags << tag.meta(property: 'og:title', content: resource.seo_og_title)
-
tags << tag.meta(property: 'og:description', content: resource.seo_og_description)
-
tags << tag.meta(property: 'og:type', content: 'article')
-
tags << tag.meta(property: 'og:url', content: resource.seo_canonical_url)
-
-
then: 0
else: 0
if resource.seo_og_image.present?
-
tags << tag.meta(property: 'og:image', content: resource.seo_og_image)
-
tags << tag.meta(property: 'og:image:alt', content: resource.title)
-
end
-
-
then: 0
else: 0
if resource.respond_to?(:published_at) && resource.published_at
-
tags << tag.meta(property: 'article:published_time', content: resource.published_at.iso8601)
-
tags << tag.meta(property: 'article:modified_time', content: resource.updated_at.iso8601)
-
end
-
-
then: 0
else: 0
if resource.respond_to?(:user) && resource.user
-
tags << tag.meta(property: 'article:author', content: resource.user.email)
-
end
-
-
# Twitter Card tags
-
tags << tag.meta(name: 'twitter:card', content: resource.twitter_card)
-
tags << tag.meta(name: 'twitter:title', content: resource.seo_twitter_title)
-
tags << tag.meta(name: 'twitter:description', content: resource.seo_twitter_description)
-
-
then: 0
else: 0
if resource.seo_twitter_image.present?
-
tags << tag.meta(name: 'twitter:image', content: resource.seo_twitter_image)
-
end
-
-
safe_join(tags, "\n")
-
end
-
-
# Render structured data (Schema.org JSON-LD)
-
1
def render_structured_data(resource)
-
else: 0
then: 0
return '' unless resource.respond_to?(:structured_data)
-
-
content_tag(:script, type: 'application/ld+json') do
-
resource.structured_data.to_json.html_safe
-
end
-
end
-
-
# Generate meta title with site name
-
1
def seo_page_title(resource_title = nil, options = {})
-
site_name = SiteSetting.get('site_title', 'RailsPress')
-
separator = options[:separator] || '|'
-
-
then: 0
if resource_title
-
"#{resource_title} #{separator} #{site_name}"
-
else: 0
else
-
site_name
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
# frozen_string_literal: true
-
-
1
module StatusHelper
-
# Get status badge CSS classes
-
1
def status_badge_class(status)
-
badge_classes = {
-
'draft' => 'bg-gray-100 text-gray-800',
-
'published' => 'bg-green-100 text-green-800',
-
'scheduled' => 'bg-blue-100 text-blue-800',
-
'pending_review' => 'bg-yellow-100 text-yellow-800',
-
'private_post' => 'bg-purple-100 text-purple-800',
-
'private_page' => 'bg-purple-100 text-purple-800',
-
'trash' => 'bg-red-100 text-red-800'
-
}
-
-
badge_classes[status.to_s] || 'bg-gray-100 text-gray-800'
-
end
-
-
# Get status badge HTML for posts/pages
-
1
def status_badge(record)
-
status = record.status
-
-
badge_classes = {
-
'draft' => 'bg-gray-500/10 text-gray-400 border-gray-500/20',
-
'published' => 'bg-green-500/10 text-green-400 border-green-500/20',
-
'scheduled' => 'bg-blue-500/10 text-blue-400 border-blue-500/20',
-
'pending_review' => 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
-
'private_post' => 'bg-purple-500/10 text-purple-400 border-purple-500/20',
-
'private_page' => 'bg-purple-500/10 text-purple-400 border-purple-500/20',
-
'trash' => 'bg-red-500/10 text-red-400 border-red-500/20'
-
}
-
-
status_labels = {
-
'draft' => 'Draft',
-
'published' => 'Published',
-
'scheduled' => 'Scheduled',
-
'pending_review' => 'Pending Review',
-
'private_post' => 'Private',
-
'private_page' => 'Private',
-
'trash' => 'Trashed'
-
}
-
-
classes = badge_classes[status] || 'bg-gray-500/10 text-gray-400'
-
label = status_labels[status] || status.titleize
-
-
content_tag(:span, label, class: "px-2 py-1 text-xs font-medium rounded border #{classes}")
-
end
-
-
# Get status icon
-
1
def status_icon(status)
-
icons = {
-
'draft' => '📝',
-
'published' => '✅',
-
'scheduled' => '⏰',
-
'pending_review' => '👀',
-
'private_post' => '🔒',
-
'private_page' => '🔒',
-
'trash' => '🗑️'
-
}
-
-
icons[status.to_s] || '📄'
-
end
-
-
# Get all statuses for filter dropdown
-
1
def post_statuses_for_select
-
[
-
['All Statuses', ''],
-
['Draft', 'draft'],
-
['Published', 'published'],
-
['Scheduled', 'scheduled'],
-
['Pending Review', 'pending_review'],
-
['Private', 'private_post']
-
]
-
end
-
-
1
def page_statuses_for_select
-
[
-
['All Statuses', ''],
-
['Draft', 'draft'],
-
['Published', 'published'],
-
['Scheduled', 'scheduled'],
-
['Pending Review', 'pending_review'],
-
['Private', 'private_page']
-
]
-
end
-
-
# Get status counts for dashboard
-
1
def post_status_counts
-
{
-
total: Post.not_trashed.count,
-
draft: Post.draft_status.count,
-
published: Post.published_status.count,
-
scheduled: Post.scheduled_status.count,
-
pending: Post.pending_review_status.count,
-
private: Post.private_post_status.count,
-
trash: Post.trash_status.count
-
}
-
end
-
-
1
def page_status_counts
-
{
-
total: Page.not_trashed.count,
-
draft: Page.draft_status.count,
-
published: Page.published_status.count,
-
scheduled: Page.scheduled_status.count,
-
pending: Page.pending_review_status.count,
-
private: Page.private_page_status.count,
-
trash: Page.trash_status.count
-
}
-
end
-
end
-
-
1
module TaxonomyHelper
-
# Get category taxonomy
-
1
def category_taxonomy
-
@category_taxonomy ||= Taxonomy.find_by(slug: 'category')
-
end
-
-
# Get tag taxonomy
-
1
def tag_taxonomy
-
@tag_taxonomy ||= Taxonomy.find_by(slug: 'tag')
-
end
-
-
# Get post format taxonomy
-
1
def post_format_taxonomy
-
@post_format_taxonomy ||= Taxonomy.find_by(slug: 'post_format')
-
end
-
-
# Get all categories
-
1
def all_categories
-
then: 0
else: 0
then: 0
else: 0
category_taxonomy&.terms&.order(:name) || Term.none
-
end
-
-
# Get all tags
-
1
def all_tags
-
then: 0
else: 0
then: 0
else: 0
tag_taxonomy&.terms&.order(:name) || Term.none
-
end
-
-
# Get categories for a post
-
1
def post_categories(post)
-
else: 0
then: 0
return [] unless category_taxonomy
-
post.terms.where(taxonomy: category_taxonomy)
-
end
-
-
# Get tags for a post
-
1
def post_tags(post)
-
else: 0
then: 0
return [] unless tag_taxonomy
-
post.terms.where(taxonomy: tag_taxonomy)
-
end
-
-
# Get category names for a post
-
1
def post_category_names(post)
-
post_categories(post).pluck(:name)
-
end
-
-
# Get tag names for a post
-
1
def post_tag_names(post)
-
post_tags(post).pluck(:name)
-
end
-
-
# Category link
-
1
def category_link(term, options = {})
-
else: 0
then: 0
return unless term
-
-
css_class = options[:class] || 'category-link'
-
link_to term.name, "/blog/category/#{term.slug}", class: css_class
-
end
-
-
# Tag link
-
1
def tag_link(term, options = {})
-
else: 0
then: 0
return unless term
-
-
css_class = options[:class] || 'tag-link'
-
link_to term.name, "/blog/tag/#{term.slug}", class: css_class
-
end
-
-
# Render category list for post
-
1
def render_post_categories(post, options = {})
-
categories = post_categories(post)
-
then: 0
else: 0
return '' if categories.empty?
-
-
separator = options[:separator] || ', '
-
css_class = options[:class] || 'post-categories'
-
-
content_tag(:div, class: css_class) do
-
categories.map { |cat| category_link(cat, options) }.join(separator).html_safe
-
end
-
end
-
-
# Render tag list for post
-
1
def render_post_tags(post, options = {})
-
tags = post_tags(post)
-
then: 0
else: 0
return '' if tags.empty?
-
-
separator = options[:separator] || ' '
-
css_class = options[:class] || 'post-tags'
-
-
content_tag(:div, class: css_class) do
-
tags.map { |tag| tag_link(tag, class: 'tag-badge') }.join(separator).html_safe
-
end
-
end
-
-
# Get term by slug and taxonomy
-
1
def find_term(taxonomy_slug, term_slug)
-
taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
-
else: 0
then: 0
return nil unless taxonomy
-
-
taxonomy.terms.friendly.find(term_slug)
-
rescue ActiveRecord::RecordNotFound
-
nil
-
end
-
-
# Get posts by category
-
1
def posts_in_category(category_slug, limit = nil)
-
term = find_term('category', category_slug)
-
else: 0
then: 0
return Post.none unless term
-
-
posts = Post.published_status.visible_to_public
-
.joins(:term_relationships)
-
.where(term_relationships: { term_id: term.id })
-
.order(published_at: :desc)
-
.distinct
-
-
then: 0
else: 0
limit ? posts.limit(limit) : posts
-
end
-
-
# Get posts by tag
-
1
def posts_with_tag(tag_slug, limit = nil)
-
term = find_term('tag', tag_slug)
-
else: 0
then: 0
return Post.none unless term
-
-
posts = Post.published_status.visible_to_public
-
.joins(:term_relationships)
-
.where(term_relationships: { term_id: term.id })
-
.order(published_at: :desc)
-
.distinct
-
-
then: 0
else: 0
limit ? posts.limit(limit) : posts
-
end
-
-
# Get popular categories (most posts)
-
1
def popular_categories(limit = 10)
-
else: 0
then: 0
return [] unless category_taxonomy
-
-
category_taxonomy.terms
-
.joins(:term_relationships)
-
.where(term_relationships: { object_type: 'Post' })
-
.group('terms.id')
-
.order('COUNT(term_relationships.id) DESC')
-
.limit(limit)
-
end
-
-
# Get popular tags (most posts)
-
1
def popular_tags(limit = 10)
-
else: 0
then: 0
return [] unless tag_taxonomy
-
-
tag_taxonomy.terms
-
.joins(:term_relationships)
-
.where(term_relationships: { object_type: 'Post' })
-
.group('terms.id')
-
.order('COUNT(term_relationships.id) DESC')
-
.limit(limit)
-
end
-
-
# Render taxonomy cloud (tags or categories)
-
1
def render_taxonomy_cloud(taxonomy_slug, options = {})
-
taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
-
else: 0
then: 0
return '' unless taxonomy
-
-
terms = taxonomy.terms
-
.joins(:term_relationships)
-
.where(term_relationships: { object_type: 'Post' })
-
.group('terms.id')
-
.select('terms.*, COUNT(term_relationships.id) as post_count')
-
.order(:name)
-
-
then: 0
else: 0
return '' if terms.empty?
-
-
max_count = terms.maximum('post_count') || 1
-
min_count = terms.minimum('post_count') || 1
-
-
content_tag(:div, class: options[:class] || 'taxonomy-cloud') do
-
terms.map do |term|
-
size = calculate_cloud_size(term.post_count, min_count, max_count)
-
link_to term.name,
-
then: 0
else: 0
taxonomy_slug == 'tag' ? "/blog/tag/#{term.slug}" : "/blog/category/#{term.slug}",
-
class: "cloud-item cloud-size-#{size}",
-
title: "#{term.post_count} posts"
-
end.join(' ').html_safe
-
end
-
end
-
-
1
private
-
-
1
def calculate_cloud_size(count, min, max)
-
then: 0
else: 0
return 3 if min == max
-
-
# Scale from 1 to 5
-
((count - min).to_f / (max - min) * 4).round + 1
-
end
-
end
-
-
1
module ToggleSwitchHelper
-
# Helper method to create a toggle switch with label
-
#
-
# Usage:
-
# <%= toggle_switch(form, :active, 'Enable Feature', description: 'Turn this feature on or off') %>
-
# <%= toggle_switch_tag('setting', '1', false, 'Enable Setting', class: 'toggle-success') %>
-
#
-
1
def toggle_switch(form, field_name, label_text, **options)
-
description = options.delete(:description)
-
size = options.delete(:size) || 'default'
-
color = options.delete(:color) || 'default'
-
-
then: 0
else: 0
wrapper_class = "toggle-with-#{description ? 'description' : 'label'}"
-
then: 0
else: 0
wrapper_class += " toggle-#{size}" if size != 'default'
-
then: 0
else: 0
wrapper_class += " toggle-#{color}" if color != 'default'
-
then: 0
else: 0
wrapper_class += " #{options.delete(:wrapper_class)}" if options[:wrapper_class]
-
-
content_tag(:div, class: wrapper_class) do
-
concat form.check_box(field_name, options)
-
-
content_tag(:div, class: 'toggle-content') do
-
concat content_tag(:label, label_text, for: "#{form.object_name}_#{field_name}")
-
then: 0
else: 0
concat content_tag(:p, description) if description.present?
-
end
-
end
-
end
-
-
1
def toggle_switch_tag(name, value, checked, label_text, **options)
-
description = options.delete(:description)
-
size = options.delete(:size) || 'default'
-
color = options.delete(:color) || 'default'
-
-
then: 0
else: 0
wrapper_class = "toggle-with-#{description ? 'description' : 'label'}"
-
then: 0
else: 0
wrapper_class += " toggle-#{size}" if size != 'default'
-
then: 0
else: 0
wrapper_class += " toggle-#{color}" if color != 'default'
-
then: 0
else: 0
wrapper_class += " #{options.delete(:wrapper_class)}" if options[:wrapper_class]
-
-
checkbox_id = options.delete(:id) || "#{name}_#{value}".gsub(/[\[\]]/, '_').gsub(/_+/, '_').chomp('_')
-
-
content_tag(:div, class: wrapper_class) do
-
concat check_box_tag(name, value, checked, options.merge(id: checkbox_id))
-
-
content_tag(:div, class: 'toggle-content') do
-
concat content_tag(:label, label_text, for: checkbox_id)
-
then: 0
else: 0
concat content_tag(:p, description) if description.present?
-
end
-
end
-
end
-
-
# Helper to create a toggle switch group
-
1
def toggle_switch_group(**options)
-
direction = options.delete(:direction) || 'vertical'
-
then: 0
else: 0
group_class = direction == 'horizontal' ? 'toggle-group-horizontal' : 'toggle-group'
-
then: 0
else: 0
group_class += " #{options[:class]}" if options[:class]
-
-
content_tag(:div, class: group_class, **options.except(:class)) do
-
then: 0
else: 0
yield if block_given?
-
end
-
end
-
-
# Helper for simple toggle switches without labels
-
1
def simple_toggle_switch(form, field_name, **options)
-
form.check_box(field_name, options)
-
end
-
-
1
def simple_toggle_switch_tag(name, value, checked, **options)
-
check_box_tag(name, value, checked, options)
-
end
-
-
# Helper to create toggle switches with different colors
-
1
def success_toggle_switch(form, field_name, label_text, **options)
-
toggle_switch(form, field_name, label_text, color: 'success', **options)
-
end
-
-
1
def warning_toggle_switch(form, field_name, label_text, **options)
-
toggle_switch(form, field_name, label_text, color: 'warning', **options)
-
end
-
-
1
def danger_toggle_switch(form, field_name, label_text, **options)
-
toggle_switch(form, field_name, label_text, color: 'danger', **options)
-
end
-
-
# Helper for different sizes
-
1
def small_toggle_switch(form, field_name, label_text, **options)
-
toggle_switch(form, field_name, label_text, size: 'sm', **options)
-
end
-
-
1
def large_toggle_switch(form, field_name, label_text, **options)
-
toggle_switch(form, field_name, label_text, size: 'lg', **options)
-
end
-
-
1
def small_toggle_switch_tag(name, value, checked, label_text, **options)
-
toggle_switch_tag(name, value, checked, label_text, size: 'sm', **options)
-
end
-
-
1
def large_toggle_switch_tag(name, value, checked, label_text, **options)
-
toggle_switch_tag(name, value, checked, label_text, size: 'lg', **options)
-
end
-
-
# Helper for loading state
-
1
def loading_toggle_switch(form, field_name, label_text, **options)
-
toggle_switch(form, field_name, label_text, wrapper_class: 'toggle-loading', **options)
-
end
-
-
# Helper for error state
-
1
def error_toggle_switch(form, field_name, label_text, **options)
-
toggle_switch(form, field_name, label_text, wrapper_class: 'toggle-error', **options)
-
end
-
-
# Helper for success state
-
1
def success_state_toggle_switch(form, field_name, label_text, **options)
-
toggle_switch(form, field_name, label_text, wrapper_class: 'toggle-success', **options)
-
end
-
end
-
# frozen_string_literal: true
-
-
class AdvancedAnalyticsProcessingJob < ApplicationJob
-
queue_as :analytics
-
-
def perform(content_id, content_type, engagement_data)
-
return unless content_id.present? && content_type.present?
-
-
begin
-
# Process advanced analytics
-
process_user_behavior_analysis(content_id, content_type, engagement_data)
-
process_content_performance_analysis(content_id, content_type, engagement_data)
-
process_engagement_patterns(content_id, content_type, engagement_data)
-
process_predictive_insights(content_id, content_type, engagement_data)
-
-
rescue => e
-
Rails.logger.error "Advanced analytics processing failed: #{e.message}"
-
raise e
-
end
-
end
-
-
private
-
-
def process_user_behavior_analysis(content_id, content_type, engagement_data)
-
# Analyze user behavior patterns
-
user_id = engagement_data[:user_id]
-
return unless user_id.present?
-
-
# Update user behavior profile
-
behavior_data = {
-
content_id: content_id,
-
content_type: content_type,
-
engagement_level: calculate_engagement_level(engagement_data),
-
reading_pattern: analyze_reading_pattern(engagement_data),
-
interaction_pattern: analyze_interaction_pattern(engagement_data),
-
timestamp: Time.current
-
}
-
-
# Store behavior analysis
-
Rails.cache.write("user_behavior:#{user_id}:latest", behavior_data, expires_in: 1.day)
-
-
# Update user cohort if applicable
-
update_user_cohort(user_id, behavior_data)
-
end
-
-
def process_content_performance_analysis(content_id, content_type, engagement_data)
-
# Analyze content performance
-
performance_data = {
-
engagement_score: calculate_content_engagement_score(engagement_data),
-
readability_score: calculate_readability_score(engagement_data),
-
retention_rate: calculate_retention_rate(content_id, content_type),
-
bounce_rate: calculate_bounce_rate(content_id, content_type),
-
conversion_potential: calculate_conversion_potential(engagement_data)
-
}
-
-
# Store performance analysis
-
cache_key = "content_performance:#{content_type.downcase}:#{content_id}"
-
Rails.cache.write(cache_key, performance_data, expires_in: 1.hour)
-
end
-
-
def process_engagement_patterns(content_id, content_type, engagement_data)
-
# Process engagement patterns for insights
-
patterns = {
-
time_patterns: analyze_time_patterns(engagement_data),
-
device_patterns: analyze_device_patterns(engagement_data),
-
geographic_patterns: analyze_geographic_patterns(engagement_data),
-
content_patterns: analyze_content_patterns(content_id, content_type)
-
}
-
-
# Store engagement patterns
-
Rails.cache.write("engagement_patterns:#{content_id}", patterns, expires_in: 6.hours)
-
end
-
-
def process_predictive_insights(content_id, content_type, engagement_data)
-
# Generate predictive insights
-
insights = {
-
content_recommendation_score: calculate_recommendation_score(content_id, content_type),
-
viral_potential: calculate_viral_potential(engagement_data),
-
engagement_prediction: predict_future_engagement(content_id, content_type),
-
user_retention_prediction: predict_user_retention(engagement_data)
-
}
-
-
# Store predictive insights
-
Rails.cache.write("predictive_insights:#{content_id}", insights, expires_in: 1.day)
-
end
-
-
def calculate_engagement_level(engagement_data)
-
score = 0
-
-
# Reading time contribution (0-40 points)
-
reading_time = engagement_data[:reading_time]&.to_i || 0
-
score += [reading_time / 10, 40].min
-
-
# Scroll depth contribution (0-30 points)
-
scroll_depth = engagement_data[:scroll_depth]&.to_i || 0
-
score += (scroll_depth * 0.3).round
-
-
# Interaction contribution (0-30 points)
-
interactions = engagement_data[:interactions]&.length || 0
-
score += [interactions * 5, 30].min
-
-
case score
-
when 0..30
-
'low'
-
when 31..60
-
'medium'
-
else
-
'high'
-
end
-
end
-
-
def analyze_reading_pattern(engagement_data)
-
reading_time = engagement_data[:reading_time]&.to_i || 0
-
scroll_depth = engagement_data[:scroll_depth]&.to_i || 0
-
-
if reading_time > 60 && scroll_depth > 80
-
'deep_reader'
-
elsif reading_time > 30 && scroll_depth > 50
-
'engaged_reader'
-
elsif reading_time > 10
-
'casual_reader'
-
else
-
'browser'
-
end
-
end
-
-
def analyze_interaction_pattern(engagement_data)
-
interactions = engagement_data[:interactions] || []
-
-
if interactions.length > 5
-
'highly_interactive'
-
elsif interactions.length > 2
-
'moderately_interactive'
-
elsif interactions.length > 0
-
'slightly_interactive'
-
else
-
'passive'
-
end
-
end
-
-
def update_user_cohort(user_id, behavior_data)
-
# Update user cohort based on behavior
-
cohort_type = determine_user_cohort(behavior_data)
-
-
if cohort_type
-
AdvancedAnalyticsService.track_user_cohort(user_id, cohort_type, behavior_data)
-
end
-
end
-
-
def determine_user_cohort(behavior_data)
-
engagement_level = behavior_data[:engagement_level]
-
reading_pattern = behavior_data[:reading_pattern]
-
-
case [engagement_level, reading_pattern]
-
when ['high', 'deep_reader']
-
'power_readers'
-
when ['medium', 'engaged_reader']
-
'engaged_readers'
-
when ['low', 'casual_reader']
-
'casual_readers'
-
else
-
'browsers'
-
end
-
end
-
-
def calculate_content_engagement_score(engagement_data)
-
# Calculate overall engagement score (0-100)
-
score = 0
-
-
# Reading time component (40%)
-
reading_time = engagement_data[:reading_time]&.to_i || 0
-
score += (reading_time / 2) * 0.4
-
-
# Scroll depth component (30%)
-
scroll_depth = engagement_data[:scroll_depth]&.to_i || 0
-
score += scroll_depth * 0.3
-
-
# Interaction component (30%)
-
interactions = engagement_data[:interactions]&.length || 0
-
score += [interactions * 10, 30].min * 0.3
-
-
[score, 100].min.round(2)
-
end
-
-
def calculate_readability_score(engagement_data)
-
# Calculate readability based on engagement metrics
-
reading_time = engagement_data[:reading_time]&.to_i || 0
-
scroll_depth = engagement_data[:scroll_depth]&.to_i || 0
-
-
# Simple readability score based on engagement
-
if reading_time > 60 && scroll_depth > 80
-
90 # Very readable
-
elsif reading_time > 30 && scroll_depth > 50
-
70 # Readable
-
elsif reading_time > 10 && scroll_depth > 20
-
50 # Somewhat readable
-
else
-
30 # Difficult to read
-
end
-
end
-
-
def calculate_retention_rate(content_id, content_type)
-
# Calculate retention rate for content
-
total_views = Pageview.where(path: get_content_path(content_id, content_type)).count
-
return 0 if total_views.zero?
-
-
retained_views = Pageview.where(
-
path: get_content_path(content_id, content_type),
-
time_on_page: 30..Float::INFINITY
-
).count
-
-
(retained_views.to_f / total_views * 100).round(2)
-
end
-
-
def calculate_bounce_rate(content_id, content_type)
-
# Calculate bounce rate for content
-
content_path = get_content_path(content_id, content_type)
-
total_sessions = Pageview.where(path: content_path).distinct.count(:session_id)
-
return 0 if total_sessions.zero?
-
-
single_page_sessions = Pageview.where(path: content_path)
-
.group(:session_id)
-
.having('COUNT(*) = 1')
-
.count
-
-
(single_page_sessions.to_f / total_sessions * 100).round(2)
-
end
-
-
def calculate_conversion_potential(engagement_data)
-
# Calculate conversion potential based on engagement
-
score = calculate_content_engagement_score(engagement_data)
-
-
case score
-
when 80..100
-
'high'
-
when 60..79
-
'medium'
-
when 40..59
-
'low'
-
else
-
'very_low'
-
end
-
end
-
-
def analyze_time_patterns(engagement_data)
-
# Analyze time-based patterns
-
timestamp = engagement_data[:timestamp] || Time.current
-
-
{
-
hour: timestamp.hour,
-
day_of_week: timestamp.wday,
-
is_weekend: timestamp.wday.in?([0, 6]),
-
is_business_hours: timestamp.hour.between?(9, 17)
-
}
-
end
-
-
def analyze_device_patterns(engagement_data)
-
# Analyze device patterns
-
user_agent = engagement_data[:user_agent] || ''
-
-
{
-
device_type: detect_device_type(user_agent),
-
browser: detect_browser(user_agent),
-
os: detect_operating_system(user_agent)
-
}
-
end
-
-
def analyze_geographic_patterns(engagement_data)
-
# Analyze geographic patterns
-
{
-
country: engagement_data[:country_code],
-
region: engagement_data[:region],
-
city: engagement_data[:city]
-
}
-
end
-
-
def analyze_content_patterns(content_id, content_type)
-
# Analyze content-specific patterns
-
content = content_type.constantize.find_by(id: content_id)
-
return {} unless content
-
-
{
-
content_length: content.content&.length || 0,
-
has_images: content.content&.include?('<img') || false,
-
has_videos: content.content&.include?('<video') || false,
-
category: content.respond_to?(:category) ? content.category&.name : nil,
-
tags: content.respond_to?(:tags) ? content.tags.pluck(:name) : []
-
}
-
end
-
-
def calculate_recommendation_score(content_id, content_type)
-
# Calculate content recommendation score
-
performance_data = Rails.cache.read("content_performance:#{content_type.downcase}:#{content_id}")
-
return 0 unless performance_data
-
-
engagement_score = performance_data[:engagement_score] || 0
-
retention_rate = performance_data[:retention_rate] || 0
-
-
(engagement_score + retention_rate) / 2
-
end
-
-
def calculate_viral_potential(engagement_data)
-
# Calculate viral potential based on engagement
-
score = calculate_content_engagement_score(engagement_data)
-
-
case score
-
when 90..100
-
'high'
-
when 70..89
-
'medium'
-
when 50..69
-
'low'
-
else
-
'very_low'
-
end
-
end
-
-
def predict_future_engagement(content_id, content_type)
-
# Predict future engagement based on current patterns
-
current_performance = Rails.cache.read("content_performance:#{content_type.downcase}:#{content_id}")
-
return 'unknown' unless current_performance
-
-
engagement_score = current_performance[:engagement_score] || 0
-
-
case engagement_score
-
when 80..100
-
'increasing'
-
when 60..79
-
'stable'
-
when 40..59
-
'decreasing'
-
else
-
'declining'
-
end
-
end
-
-
def predict_user_retention(engagement_data)
-
# Predict user retention based on engagement
-
engagement_level = calculate_engagement_level(engagement_data)
-
reading_pattern = analyze_reading_pattern(engagement_data)
-
-
case [engagement_level, reading_pattern]
-
when ['high', 'deep_reader']
-
'high'
-
when ['medium', 'engaged_reader']
-
'medium'
-
when ['low', 'casual_reader']
-
'low'
-
else
-
'very_low'
-
end
-
end
-
-
def get_content_path(content_id, content_type)
-
# Get the path for content
-
case content_type
-
when 'Post'
-
post = Post.find_by(id: content_id)
-
post ? "/posts/#{post.slug}" : nil
-
when 'Page'
-
page = Page.find_by(id: content_id)
-
page ? "/pages/#{page.slug}" : nil
-
else
-
nil
-
end
-
end
-
-
def detect_device_type(user_agent)
-
user_agent = user_agent.downcase
-
-
if user_agent.include?('mobile') || user_agent.include?('android') || user_agent.include?('iphone')
-
'mobile'
-
elsif user_agent.include?('tablet') || user_agent.include?('ipad')
-
'tablet'
-
else
-
'desktop'
-
end
-
end
-
-
def detect_browser(user_agent)
-
user_agent = user_agent.downcase
-
-
if user_agent.include?('chrome')
-
'Chrome'
-
elsif user_agent.include?('firefox')
-
'Firefox'
-
elsif user_agent.include?('safari')
-
'Safari'
-
elsif user_agent.include?('edge')
-
'Edge'
-
else
-
'Other'
-
end
-
end
-
-
def detect_operating_system(user_agent)
-
user_agent = user_agent.downcase
-
-
if user_agent.include?('windows')
-
'Windows'
-
elsif user_agent.include?('mac')
-
'macOS'
-
elsif user_agent.include?('linux')
-
'Linux'
-
elsif user_agent.include?('android')
-
'Android'
-
elsif user_agent.include?('ios')
-
'iOS'
-
else
-
'Other'
-
end
-
end
-
end
-
class AnalyticsArchiveJob < ApplicationJob
-
queue_as :default
-
-
def perform
-
Rails.logger.info "Starting analytics archive job"
-
-
begin
-
archived_count = AnalyticsArchiveService.instance.archive_old_data
-
-
Rails.logger.info "Analytics archive job completed successfully. Archived #{archived_count} records."
-
-
# Schedule next archive job
-
AnalyticsArchiveService.instance.schedule_auto_archive
-
-
rescue => e
-
Rails.logger.error "Analytics archive job failed: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
-
# Retry the job with exponential backoff
-
raise e
-
end
-
end
-
end
-
class AnalyticsProcessingJob < ApplicationJob
-
queue_as :analytics
-
-
def perform(pageview_data)
-
# Process pageview data in background for better performance
-
begin
-
# Batch process multiple pageviews if data is an array
-
if pageview_data.is_a?(Array)
-
process_batch(pageview_data)
-
else
-
process_single(pageview_data)
-
end
-
rescue => e
-
Rails.logger.error "Analytics processing failed: #{e.message}"
-
# Don't retry to avoid infinite loops
-
end
-
end
-
-
private
-
-
def process_single(data)
-
# Create pageview with error handling
-
Pageview.create!(data)
-
rescue ActiveRecord::RecordInvalid => e
-
Rails.logger.error "Invalid pageview data: #{e.message}"
-
end
-
-
def process_batch(pageview_data_array)
-
# Batch insert for better performance
-
Pageview.insert_all(pageview_data_array, on_duplicate: :ignore)
-
rescue => e
-
Rails.logger.error "Batch analytics processing failed: #{e.message}"
-
end
-
end
-
class AnalyticsRetentionJob < ApplicationJob
-
queue_as :low_priority
-
-
def perform
-
# Run analytics data retention cleanup
-
AnalyticsRetentionService.cleanup_old_data
-
-
# Schedule next cleanup (weekly)
-
AnalyticsRetentionJob.set(wait: 1.week).perform_later
-
rescue => e
-
Rails.logger.error "Analytics retention job failed: #{e.message}"
-
end
-
end
-
class ApplicationJob < ActiveJob::Base
-
# Automatically retry jobs that encountered a deadlock
-
# retry_on ActiveRecord::Deadlocked
-
-
# Most jobs are safe to ignore if the underlying records are no longer available
-
# discard_on ActiveJob::DeserializationError
-
end
-
class CheckUpdatesJob < ApplicationJob
-
queue_as :default
-
-
def perform
-
# Check for updates from GitHub
-
update_info = Railspress::UpdateChecker.check_for_updates
-
-
# If update is available, notify administrators
-
if update_info[:update_available]
-
notify_administrators(update_info)
-
end
-
-
# Log the check
-
Rails.logger.info "Update check completed: Current #{update_info[:current_version]}, Latest #{update_info[:latest_version]}"
-
end
-
-
private
-
-
def notify_administrators
-
# Find all administrator users
-
User.administrator.find_each do |admin|
-
# Send notification (could be email, in-app notification, etc.)
-
Rails.logger.info "Notifying admin #{admin.email} of available update"
-
-
# TODO: Implement actual notification system
-
# UpdateNotificationMailer.update_available(admin, update_info).deliver_later
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
# frozen_string_literal: true
-
-
class ContentAnalyticsUpdateJob < ApplicationJob
-
queue_as :analytics
-
-
def perform(content_id, content_type, engagement_data)
-
return unless content_id.present? && content_type.present?
-
-
begin
-
# Update content analytics in the database
-
update_content_metrics(content_id, content_type, engagement_data)
-
-
# Update real-time analytics cache
-
update_realtime_cache(content_id, content_type, engagement_data)
-
-
# Trigger advanced analytics processing
-
trigger_advanced_processing(content_id, content_type, engagement_data)
-
-
rescue => e
-
Rails.logger.error "Content analytics update failed: #{e.message}"
-
raise e
-
end
-
end
-
-
private
-
-
def update_content_metrics(content_id, content_type, engagement_data)
-
# Update aggregated metrics for content
-
case content_type
-
when 'Post'
-
update_post_metrics(content_id, engagement_data)
-
when 'Page'
-
update_page_metrics(content_id, engagement_data)
-
end
-
end
-
-
def update_post_metrics(post_id, engagement_data)
-
post = Post.find_by(id: post_id)
-
return unless post
-
-
# Update post engagement metrics
-
post_analytics = ContentAnalyticsService.post_analytics(post_id, period: :month)
-
-
# Cache the updated analytics
-
Rails.cache.write("post_analytics:#{post_id}:month", post_analytics, expires_in: 1.hour)
-
end
-
-
def update_page_metrics(page_id, engagement_data)
-
page = Page.find_by(id: page_id)
-
return unless page
-
-
# Update page engagement metrics
-
page_analytics = ContentAnalyticsService.page_analytics(page_id, period: :month)
-
-
# Cache the updated analytics
-
Rails.cache.write("page_analytics:#{page_id}:month", page_analytics, expires_in: 1.hour)
-
end
-
-
def update_realtime_cache(content_id, content_type, engagement_data)
-
# Update real-time engagement cache
-
cache_key = "realtime_engagement:#{content_type.downcase}:#{content_id}"
-
-
current_data = Rails.cache.read(cache_key) || {}
-
updated_data = current_data.merge(engagement_data)
-
-
Rails.cache.write(cache_key, updated_data, expires_in: 5.minutes)
-
end
-
-
def trigger_advanced_processing(content_id, content_type, engagement_data)
-
# Trigger advanced analytics processing if engagement is significant
-
if significant_engagement?(engagement_data)
-
AdvancedAnalyticsProcessingJob.perform_later(content_id, content_type, engagement_data)
-
end
-
end
-
-
def significant_engagement?(engagement_data)
-
# Consider engagement significant if:
-
# - Reading time > 30 seconds
-
# - Scroll depth > 50%
-
# - Multiple interactions
-
-
reading_time = engagement_data[:reading_time]&.to_i || 0
-
scroll_depth = engagement_data[:scroll_depth]&.to_i || 0
-
interactions = engagement_data[:interactions]&.length || 0
-
-
reading_time > 30 || scroll_depth > 50 || interactions > 3
-
end
-
end
-
class DeliverWebhookJob < ApplicationJob
-
queue_as :default
-
-
# Retry with exponential backoff
-
retry_on StandardError, wait: :exponentially_longer, attempts: 5
-
-
def perform(webhook_delivery_id)
-
delivery = WebhookDelivery.find(webhook_delivery_id)
-
webhook = delivery.webhook
-
-
# Skip if webhook is inactive
-
return unless webhook.active?
-
-
# Prepare request
-
uri = URI(webhook.url)
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = (uri.scheme == 'https')
-
http.open_timeout = webhook.timeout
-
http.read_timeout = webhook.timeout
-
-
# Create request
-
request = Net::HTTP::Post.new(uri.path.empty? ? '/' : uri.path)
-
-
# Set headers
-
delivery.signed_headers.each do |key, value|
-
request[key] = value
-
end
-
-
# Set body
-
request.body = delivery.payload.to_json
-
-
# Send request
-
begin
-
response = http.request(request)
-
-
if response.code.to_i >= 200 && response.code.to_i < 300
-
# Success
-
delivery.mark_success!(response.code.to_i, response.body)
-
-
Rails.logger.info "Webhook delivered successfully: #{webhook.name} (#{delivery.event_type})"
-
else
-
# HTTP error
-
delivery.mark_failed!(
-
"HTTP #{response.code}: #{response.message}",
-
response.code.to_i,
-
response.body
-
)
-
-
Rails.logger.warn "Webhook delivery failed: #{webhook.name} (HTTP #{response.code})"
-
end
-
rescue Timeout::Error => e
-
delivery.mark_failed!("Request timeout after #{webhook.timeout}s")
-
Rails.logger.error "Webhook timeout: #{webhook.name} - #{e.message}"
-
rescue SocketError, Errno::ECONNREFUSED => e
-
delivery.mark_failed!("Connection failed: #{e.message}")
-
Rails.logger.error "Webhook connection failed: #{webhook.name} - #{e.message}"
-
rescue => e
-
delivery.mark_failed!("Unexpected error: #{e.message}")
-
Rails.logger.error "Webhook error: #{webhook.name} - #{e.class}: #{e.message}"
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
# frozen_string_literal: true
-
-
class MaxmindUpdateJob < ApplicationJob
-
queue_as :default
-
-
def perform(update_type = :full)
-
begin
-
Rails.logger.info "Starting MaxMind database update: #{update_type}"
-
-
case update_type
-
when :full
-
MaxmindUpdaterService.update_databases
-
when :city
-
MaxmindUpdaterService.download_database('GeoLite2-City')
-
when :country
-
MaxmindUpdaterService.download_database('GeoLite2-Country')
-
end
-
-
Rails.logger.info "MaxMind database update completed successfully"
-
-
# Update the last update timestamp
-
SiteSetting.set('maxmind_last_update', Time.current.iso8601)
-
-
# Send notification if configured
-
send_update_notification if SiteSetting.get('maxmind_notifications_enabled', false)
-
-
rescue => e
-
Rails.logger.error "MaxMind database update failed: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
-
# Send error notification
-
send_error_notification(e) if SiteSetting.get('maxmind_notifications_enabled', false)
-
-
raise e
-
end
-
end
-
-
private
-
-
def send_update_notification
-
# Send notification to admin users about successful update
-
admin_users = User.where(administrator: true)
-
-
admin_users.each do |user|
-
# You could implement email notifications here
-
Rails.logger.info "MaxMind update notification sent to #{user.email}"
-
end
-
end
-
-
def send_error_notification(error)
-
# Send error notification to admin users
-
admin_users = User.where(administrator: true)
-
-
admin_users.each do |user|
-
# You could implement error email notifications here
-
Rails.logger.error "MaxMind update error notification sent to #{user.email}: #{error.message}"
-
end
-
end
-
end
-
# Background job for image optimization
-
class OptimizeImageJob < ApplicationJob
-
queue_as :default
-
-
def perform(medium_id:, optimization_type: 'upload', request_context: {})
-
medium = Medium.find_by(id: medium_id)
-
return unless medium&.upload&.file&.attached?
-
return unless medium.image?
-
-
Rails.logger.info "Starting image optimization for medium #{medium_id}"
-
-
# Use the ImageOptimizationService with logging
-
optimization_service = ImageOptimizationService.new(
-
medium,
-
optimization_type: optimization_type,
-
request_context: request_context
-
)
-
-
# Optimize the main image
-
if optimization_service.optimize!
-
Rails.logger.info "Main image optimization completed for medium #{medium_id}"
-
else
-
Rails.logger.info "Main image optimization skipped for medium #{medium_id}"
-
end
-
-
Rails.logger.info "Image optimization process completed for medium #{medium_id}"
-
rescue => e
-
Rails.logger.error "Image optimization failed for medium #{medium_id}: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
end
-
end
-
class PluginTaskWorkerJob < ApplicationJob
-
queue_as :default
-
-
def perform(plugin_identifier, task_name)
-
plugin = Railspress::PluginSystem.get_plugin(plugin_identifier)
-
-
unless plugin
-
Rails.logger.error "Plugin not found: #{plugin_identifier}"
-
return
-
end
-
-
# Find the task
-
task = Railspress::PluginSystem.instance_variable_get(:@scheduled_tasks)[plugin_identifier]
-
&.find { |t| t[:name] == task_name }
-
-
unless task
-
Rails.logger.error "Task not found: #{plugin_identifier}:#{task_name}"
-
return
-
end
-
-
Rails.logger.info "Executing scheduled task: #{plugin_identifier}:#{task_name}"
-
-
begin
-
# Execute the task
-
task[:block].call
-
Rails.logger.info "Task completed successfully: #{plugin_identifier}:#{task_name}"
-
rescue => e
-
Rails.logger.error "Task failed: #{plugin_identifier}:#{task_name} - #{e.message}"
-
Rails.logger.error e.backtrace.first(5).join("\n")
-
raise e
-
end
-
end
-
end
-
# FluentFormsIntegrationJob
-
# Handles third-party integrations (Slack, Mailchimp, Webhooks, etc.)
-
-
class FluentFormsIntegrationJob < ApplicationJob
-
queue_as :default
-
-
def perform(submission_id)
-
@submission = fetch_submission(submission_id)
-
return unless @submission
-
-
@form = fetch_form(@submission[:form_id])
-
return unless @form
-
-
@plugin = FluentFormsPro.new
-
-
# Process all enabled integrations
-
process_slack_integration if slack_enabled?
-
process_mailchimp_integration if mailchimp_enabled?
-
process_webhook_integration if webhook_enabled?
-
process_zapier_integration if zapier_enabled?
-
-
log_integration(submission_id, 'Integrations processed successfully')
-
-
rescue => e
-
Rails.logger.error "[Fluent Forms] Integration error: #{e.message}"
-
log_integration(submission_id, "Integration failed: #{e.message}")
-
end
-
-
private
-
-
def fetch_submission(submission_id)
-
result = ActiveRecord::Base.connection.execute(
-
"SELECT * FROM ff_submissions WHERE id = ? LIMIT 1",
-
submission_id
-
).first
-
-
return nil unless result
-
-
{
-
id: result[0],
-
form_id: result[1],
-
serial_number: result[2],
-
response_data: JSON.parse(result[3] || '{}'),
-
created_at: result[14]
-
}
-
end
-
-
def fetch_form(form_id)
-
result = ActiveRecord::Base.connection.execute(
-
"SELECT * FROM ff_forms WHERE id = ? LIMIT 1",
-
form_id
-
).first
-
-
return nil unless result
-
-
{
-
id: result[0],
-
title: result[1],
-
settings: JSON.parse(result[3] || '{}')
-
}
-
end
-
-
# Slack Integration
-
def slack_enabled?
-
webhook_url = @plugin.get_setting('slack_webhook_url')
-
integrations = @form[:settings][:integrations] || {}
-
webhook_url.present? && integrations.dig(:slack, :enabled)
-
end
-
-
def process_slack_integration
-
webhook_url = @plugin.get_setting('slack_webhook_url')
-
-
message = format_slack_message
-
-
uri = URI(webhook_url)
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
-
request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
-
request.body = message.to_json
-
-
response = http.request(request)
-
-
if response.code.to_i == 200
-
Rails.logger.info "[Fluent Forms] Slack notification sent for submission #{@submission[:id]}"
-
else
-
Rails.logger.error "[Fluent Forms] Slack notification failed: #{response.body}"
-
end
-
end
-
-
def format_slack_message
-
fields = @submission[:response_data].map do |key, value|
-
{
-
title: key.titleize,
-
value: value,
-
short: value.to_s.length < 40
-
}
-
end
-
-
{
-
text: "New form submission: #{@form[:title]}",
-
attachments: [
-
{
-
color: '#36a64f',
-
fields: fields,
-
footer: 'Fluent Forms Pro',
-
ts: Time.current.to_i
-
}
-
]
-
}
-
end
-
-
# Mailchimp Integration
-
def mailchimp_enabled?
-
api_key = @plugin.get_setting('mailchimp_api_key')
-
integrations = @form[:settings][:integrations] || {}
-
api_key.present? && integrations.dig(:mailchimp, :enabled)
-
end
-
-
def process_mailchimp_integration
-
api_key = @plugin.get_setting('mailchimp_api_key')
-
list_id = @form[:settings].dig(:integrations, :mailchimp, :list_id)
-
-
return unless list_id.present?
-
-
email = find_email_in_submission
-
return unless email.present?
-
-
# Extract datacenter from API key
-
datacenter = api_key.split('-').last
-
-
uri = URI("https://#{datacenter}.api.mailchimp.com/3.0/lists/#{list_id}/members")
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
-
request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
-
request.basic_auth('apikey', api_key)
-
request.body = {
-
email_address: email,
-
status: 'subscribed',
-
merge_fields: extract_merge_fields
-
}.to_json
-
-
response = http.request(request)
-
-
if response.code.to_i.between?(200, 299)
-
Rails.logger.info "[Fluent Forms] Mailchimp subscriber added for submission #{@submission[:id]}"
-
else
-
Rails.logger.error "[Fluent Forms] Mailchimp failed: #{response.body}"
-
end
-
rescue => e
-
Rails.logger.error "[Fluent Forms] Mailchimp error: #{e.message}"
-
end
-
-
def extract_merge_fields
-
fields = {}
-
-
# Map common fields
-
if @submission[:response_data]['name'].present?
-
name_parts = @submission[:response_data]['name'].split(' ', 2)
-
fields['FNAME'] = name_parts[0]
-
fields['LNAME'] = name_parts[1] if name_parts[1]
-
end
-
-
fields['FNAME'] = @submission[:response_data]['first_name'] if @submission[:response_data]['first_name']
-
fields['LNAME'] = @submission[:response_data]['last_name'] if @submission[:response_data]['last_name']
-
fields['PHONE'] = @submission[:response_data]['phone'] if @submission[:response_data]['phone']
-
-
fields
-
end
-
-
# Webhook Integration
-
def webhook_enabled?
-
webhooks = @form[:settings].dig(:integrations, :webhooks) || []
-
webhooks.any? { |w| w[:enabled] }
-
end
-
-
def process_webhook_integration
-
webhooks = @form[:settings].dig(:integrations, :webhooks) || []
-
-
webhooks.each do |webhook|
-
next unless webhook[:enabled] && webhook[:url].present?
-
-
send_webhook(webhook)
-
end
-
end
-
-
def send_webhook(webhook)
-
uri = URI(webhook[:url])
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = uri.scheme == 'https'
-
-
request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
-
request.body = {
-
form_id: @form[:id],
-
form_title: @form[:title],
-
submission_id: @submission[:id],
-
serial_number: @submission[:serial_number],
-
data: @submission[:response_data],
-
created_at: @submission[:created_at]
-
}.to_json
-
-
response = http.request(request)
-
-
if response.code.to_i.between?(200, 299)
-
Rails.logger.info "[Fluent Forms] Webhook sent to #{webhook[:url]}"
-
else
-
Rails.logger.error "[Fluent Forms] Webhook failed: #{response.code} - #{response.body}"
-
end
-
rescue => e
-
Rails.logger.error "[Fluent Forms] Webhook error: #{e.message}"
-
end
-
-
# Zapier Integration
-
def zapier_enabled?
-
@plugin.setting_enabled?('zapier_enabled')
-
end
-
-
def process_zapier_integration
-
zapier_webhook = @form[:settings].dig(:integrations, :zapier, :webhook_url)
-
return unless zapier_webhook.present?
-
-
send_webhook({ url: zapier_webhook, enabled: true })
-
end
-
-
# Helper methods
-
def find_email_in_submission
-
@submission[:response_data].values.find { |v| v.to_s.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i) }
-
end
-
-
def log_integration(submission_id, message)
-
ActiveRecord::Base.connection.execute(
-
"INSERT INTO ff_logs (submission_id, form_id, log_type, title, description, created_at)
-
VALUES (?, ?, ?, ?, ?, ?)",
-
submission_id,
-
@submission[:form_id],
-
'integration',
-
'Third-party Integration',
-
message,
-
Time.current
-
)
-
rescue => e
-
Rails.logger.error "[Fluent Forms] Log error: #{e.message}"
-
end
-
end
-
-
# FluentFormsNotificationJob
-
# Sends email notifications when forms are submitted
-
-
class FluentFormsNotificationJob < ApplicationJob
-
queue_as :default
-
-
def perform(submission_id)
-
@submission = fetch_submission(submission_id)
-
return unless @submission
-
-
@form = fetch_form(@submission[:form_id])
-
return unless @form
-
-
# Send admin notification
-
send_admin_notification if admin_notification_enabled?
-
-
# Send user notification (autoresponder)
-
send_user_notification if user_notification_enabled?
-
-
# Log notification
-
log_notification(submission_id, 'Notifications sent successfully')
-
-
rescue => e
-
Rails.logger.error "[Fluent Forms] Notification error: #{e.message}"
-
log_notification(submission_id, "Notification failed: #{e.message}")
-
end
-
-
private
-
-
def fetch_submission(submission_id)
-
result = ActiveRecord::Base.connection.execute(
-
"SELECT * FROM ff_submissions WHERE id = ? LIMIT 1",
-
submission_id
-
).first
-
-
return nil unless result
-
-
{
-
id: result[0],
-
form_id: result[1],
-
serial_number: result[2],
-
response_data: JSON.parse(result[3] || '{}'),
-
source_url: result[4],
-
user_id: result[5],
-
browser: result[6],
-
device: result[7],
-
ip_address: result[8],
-
created_at: result[14]
-
}
-
end
-
-
def fetch_form(form_id)
-
result = ActiveRecord::Base.connection.execute(
-
"SELECT * FROM ff_forms WHERE id = ? LIMIT 1",
-
form_id
-
).first
-
-
return nil unless result
-
-
{
-
id: result[0],
-
title: result[1],
-
settings: JSON.parse(result[3] || '{}')
-
}
-
end
-
-
def admin_notification_enabled?
-
notifications = @form[:settings].dig(:notifications, :admin)
-
notifications && notifications[:enabled]
-
end
-
-
def user_notification_enabled?
-
notifications = @form[:settings].dig(:notifications, :user)
-
notifications && notifications[:enabled]
-
end
-
-
def send_admin_notification
-
plugin = FluentFormsPro.new
-
-
admin_email = @form[:settings].dig(:notifications, :admin, :email) ||
-
plugin.get_setting('default_from_email')
-
subject = @form[:settings].dig(:notifications, :admin, :subject) ||
-
"New form submission: #{@form[:title]}"
-
-
return unless admin_email.present?
-
-
FluentFormsMailer.admin_notification(
-
to: admin_email,
-
subject: subject,
-
form: @form,
-
submission: @submission
-
).deliver_now
-
end
-
-
def send_user_notification
-
plugin = FluentFormsPro.new
-
-
# Find email field in submission
-
user_email = find_email_in_submission
-
return unless user_email.present?
-
-
subject = @form[:settings].dig(:notifications, :user, :subject) ||
-
"Thank you for your submission"
-
message = @form[:settings].dig(:notifications, :user, :message) ||
-
"We have received your submission."
-
-
FluentFormsMailer.user_notification(
-
to: user_email,
-
subject: subject,
-
message: message,
-
form: @form,
-
submission: @submission
-
).deliver_now
-
end
-
-
def find_email_in_submission
-
@submission[:response_data].values.find { |v| v.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i) }
-
end
-
-
def log_notification(submission_id, message)
-
ActiveRecord::Base.connection.execute(
-
"INSERT INTO ff_logs (submission_id, form_id, log_type, title, description, created_at)
-
VALUES (?, ?, ?, ?, ?, ?)",
-
submission_id,
-
@submission[:form_id],
-
'notification',
-
'Email Notification',
-
message,
-
Time.current
-
)
-
rescue => e
-
Rails.logger.error "[Fluent Forms] Log error: #{e.message}"
-
end
-
end
-
-
class WebhookJob < ApplicationJob
-
queue_as :default
-
-
retry_on StandardError, wait: :exponentially_longer, attempts: 3
-
-
def perform(webhook_config, data)
-
webhook = webhook_config.with_indifferent_access
-
-
uri = URI(webhook[:url])
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = uri.scheme == 'https'
-
http.read_timeout = webhook[:timeout] || 30
-
-
request_class = case webhook[:method]&.upcase
-
when 'GET'
-
Net::HTTP::Get
-
when 'PUT'
-
Net::HTTP::Put
-
when 'PATCH'
-
Net::HTTP::Patch
-
when 'DELETE'
-
Net::HTTP::Delete
-
else
-
Net::HTTP::Post
-
end
-
-
request = request_class.new(uri)
-
request['Content-Type'] = 'application/json'
-
request['User-Agent'] = 'RailsPress-Plugin-Webhook/1.0'
-
-
# Add custom headers
-
webhook[:headers]&.each do |key, value|
-
request[key] = value
-
end
-
-
# Add webhook signature if secret is provided
-
if webhook[:secret]
-
payload = data.to_json
-
signature = OpenSSL::HMAC.hexdigest('SHA256', webhook[:secret], payload)
-
request['X-Webhook-Signature'] = "sha256=#{signature}"
-
end
-
-
# Prepare payload
-
payload = data.to_json
-
request.body = payload
-
-
# Send request
-
response = http.request(request)
-
-
Rails.logger.info "Webhook sent to #{webhook[:url]}: #{response.code} #{response.message}"
-
-
# Raise error for non-success responses to trigger retry
-
unless response.is_a?(Net::HTTPSuccess)
-
raise "Webhook failed with status #{response.code}: #{response.message}"
-
end
-
-
rescue => e
-
Rails.logger.error "Webhook delivery failed: #{e.message}"
-
raise e # Re-raise to trigger retry mechanism
-
end
-
end
-
class ApplicationMailer < ActionMailer::Base
-
default from: "from@example.com"
-
layout "mailer"
-
end
-
1
class EmailLoggingInterceptor
-
1
def self.delivering_email(message)
-
else: 0
then: 0
return unless ActiveRecord::Base.connection.table_exists?('email_logs')
-
else: 0
then: 0
return unless ActiveRecord::Base.connection.table_exists?('site_settings')
-
-
# Only log if logging is enabled
-
else: 0
then: 0
return unless SiteSetting.get('email_logging_enabled', true)
-
-
provider = SiteSetting.get('email_provider', 'smtp')
-
-
# Log the email
-
EmailLog.log_email(
-
from: extract_email(message.from),
-
to: extract_email(message.to),
-
subject: message.subject,
-
then: 0
else: 0
body: message.body&.raw_source || message.body.to_s,
-
provider: provider,
-
status: 'pending',
-
metadata: {
-
cc: message.cc,
-
bcc: message.bcc,
-
reply_to: message.reply_to,
-
message_id: message.message_id,
-
content_type: message.content_type
-
}
-
)
-
rescue => e
-
Rails.logger.error "Failed to log email: #{e.message}"
-
end
-
-
1
def self.extract_email(email_field)
-
then: 0
else: 0
return nil if email_field.nil?
-
-
then: 0
if email_field.is_a?(Array)
-
email_field.first.to_s
-
else: 0
else
-
email_field.to_s
-
end
-
end
-
end
-
-
# FluentFormsMailer
-
# Handles email notifications for form submissions
-
-
class FluentFormsMailer < ApplicationMailer
-
default from: -> { default_from_email }
-
-
# Admin notification email
-
def admin_notification(to:, subject:, form:, submission:)
-
@form = form
-
@submission = submission
-
@subject = subject
-
-
mail(
-
to: to,
-
subject: subject,
-
template_path: 'fluent_forms_mailer',
-
template_name: 'admin_notification'
-
)
-
end
-
-
# User notification email (autoresponder)
-
def user_notification(to:, subject:, message:, form:, submission:)
-
@form = form
-
@submission = submission
-
@message = message
-
@subject = subject
-
-
mail(
-
to: to,
-
subject: subject,
-
template_path: 'fluent_forms_mailer',
-
template_name: 'user_notification'
-
)
-
end
-
-
private
-
-
def default_from_email
-
plugin = FluentFormsPro.new
-
from_email = plugin.get_setting('default_from_email')
-
from_name = plugin.get_setting('default_from_name')
-
-
if from_name.present?
-
"#{from_name} <#{from_email}>"
-
else
-
from_email || 'noreply@example.com'
-
end
-
end
-
end
-
-
class TestMailer < ApplicationMailer
-
def test_email(to_address)
-
@test_time = Time.current
-
-
from_email = SiteSetting.get('default_from_email', 'noreply@railspress.com')
-
from_name = SiteSetting.get('default_from_name', 'RailsPress')
-
-
mail(
-
from: "#{from_name} <#{from_email}>",
-
to: to_address,
-
subject: "Test Email from RailsPress - #{@test_time.strftime('%Y-%m-%d %H:%M:%S')}"
-
)
-
end
-
end
-
-
-
-
-
-
-
-
-
1
class AllowIframeForLogs
-
1
def initialize(app)
-
@app = app
-
end
-
-
1
def call(env)
-
status, headers, body = @app.call(env)
-
-
then: 0
else: 0
if env['PATH_INFO'].start_with?('/logs')
-
headers.delete('X-Frame-Options') # Remove restrictive header
-
headers['Content-Security-Policy'] =
-
[headers['Content-Security-Policy'],
-
"frame-ancestors 'self'"].compact.join('; ')
-
end
-
-
[status, headers, body]
-
end
-
end
-
1
class AnalyticsTracker
-
1
def initialize(app)
-
1
@app = app
-
end
-
-
1
def call(env)
-
status, headers, response = @app.call(env)
-
-
# Track pageview after response (non-blocking)
-
then: 0
else: 0
track_pageview(env, status) if should_track?(env, status)
-
-
[status, headers, response]
-
end
-
-
1
private
-
-
1
def should_track?(env, status)
-
request = Rack::Request.new(env)
-
-
# Track successful GET and POST requests (for form submissions, etc.)
-
else: 0
then: 0
return false unless (request.get? || request.post?) && status == 200
-
-
# Skip admin, API, assets
-
then: 0
else: 0
return false if skip_path?(request.path)
-
-
# Skip if tracking disabled
-
else: 0
then: 0
return false unless tracking_enabled?
-
-
# Track everything else
-
true
-
end
-
-
1
def skip_path?(path)
-
skip_patterns = [
-
/^\/admin/,
-
/^\/api/,
-
/^\/assets/,
-
/^\/packs/,
-
/^\/uploads/,
-
/^\/rails/,
-
/^\/cable/,
-
/^\/up$/,
-
/^\/analytics\/track/, # Our own tracking endpoint
-
/\.json$/,
-
/\.xml$/,
-
/\.js$/,
-
/\.css$/,
-
/\.png$/,
-
/\.jpg$/,
-
/\.gif$/,
-
/\.ico$/
-
]
-
-
skip_patterns.any? { |pattern| path.match?(pattern) }
-
end
-
-
1
def tracking_enabled?
-
# Check if analytics is enabled in settings
-
SiteSetting.get('analytics_enabled', 'true') == 'true'
-
rescue
-
true # Default to enabled
-
end
-
-
1
def consent_required?
-
SiteSetting.get('analytics_require_consent', 'true') == 'true'
-
rescue
-
true
-
end
-
-
1
def anonymize_ip?
-
SiteSetting.get('analytics_anonymize_ip', 'true') == 'true'
-
rescue
-
true
-
end
-
-
1
def track_bots?
-
SiteSetting.get('analytics_track_bots', 'false') == 'true'
-
rescue
-
false
-
end
-
-
1
def track_pageview(env, status)
-
request = Rack::Request.new(env)
-
-
# Skip if consent is required but not given
-
then: 0
else: 0
if consent_required? && !check_consent(request)
-
return
-
end
-
-
# Background job for tracking (non-blocking)
-
# For now, track synchronously but could use Sidekiq
-
Thread.new do
-
begin
-
Pageview.track(request, {
-
title: extract_title(env),
-
user_id: extract_user_id(env),
-
session_id: extract_session_id(request),
-
consented: check_consent(request) || !consent_required?,
-
track_bots: track_bots?,
-
anonymize_ip: anonymize_ip?
-
})
-
rescue => e
-
Rails.logger.error "Analytics tracking error: #{e.message}"
-
end
-
end
-
end
-
-
1
def extract_title(env)
-
# Try to extract page title from response
-
# This is a simplified version
-
nil
-
end
-
-
1
def extract_user_id(env)
-
# Try to get current user from session
-
session = env['rack.session']
-
then: 0
else: 0
then: 0
else: 0
session['warden.user.user.key']&.first&.first
-
rescue
-
nil
-
end
-
-
1
def extract_session_id(request)
-
# Use cookie-based session or generate new one
-
request.cookies['_railspress_session_id'] ||
-
Digest::SHA256.hexdigest("#{request.ip}-#{request.user_agent}-#{Date.today}")[0..31]
-
end
-
-
1
def check_consent(request)
-
# Check if user has given analytics consent
-
# Could be from cookie or session
-
request.cookies['analytics_consent'] == 'true'
-
end
-
end
-
-
-
-
-
-
-
-
-
1
module Railspress
-
1
class ChannelDetectionMiddleware
-
1
def initialize(app)
-
1
@app = app
-
end
-
-
1
def call(env)
-
request = ActionDispatch::Request.new(env)
-
-
# Only apply channel detection to API requests
-
then: 0
else: 0
if api_request?(request)
-
user_agent = request.user_agent || ''
-
device_type = detect_device_type(user_agent)
-
channel = channel_for_device(device_type)
-
-
# Add channel context to request parameters
-
then: 0
else: 0
if channel
-
request.params[:auto_channel] = channel.slug
-
request.params[:device_type] = device_type.to_s
-
request.params[:channel_context] = channel.slug
-
end
-
end
-
-
@app.call(env)
-
end
-
-
1
private
-
-
1
def api_request?(request)
-
request.path.start_with?('/api/')
-
end
-
-
1
def detect_device_type(user_agent)
-
then: 0
else: 0
return :email if email_client?(user_agent)
-
-
# Mobile detection
-
then: 0
else: 0
if user_agent.match?(/iPhone|Android|Mobile|BlackBerry|Windows Phone|Opera Mini|IEMobile|webOS|Palm|Nokia/i)
-
return :mobile
-
end
-
-
# Tablet detection
-
then: 0
else: 0
if user_agent.match?(/iPad|Android.*Tablet|Kindle|Silk|PlayBook|BB10|Tablet|Nexus 7|Nexus 10/i)
-
return :tablet
-
end
-
-
# Smart TV detection
-
then: 0
else: 0
if user_agent.match?(/SmartTV|TV|Roku|AppleTV|AndroidTV|WebOS|Tizen|NetCast|BRAVIA|Samsung|LG/i)
-
return :smart_tv
-
end
-
-
# Default to desktop
-
:desktop
-
end
-
-
1
def email_client?(user_agent)
-
user_agent.match?(/Outlook|Gmail|Apple Mail|Thunderbird|Mail|Yahoo Mail|Hotmail|AOL|Zimbra/i)
-
end
-
-
1
def channel_for_device(device_type)
-
case device_type
-
when: 0
when :mobile, :tablet
-
Channel.find_by(slug: 'mobile')
-
when: 0
when :smart_tv
-
Channel.find_by(slug: 'smarttv')
-
when: 0
when :email
-
Channel.find_by(slug: 'newsletter')
-
else: 0
else
-
Channel.find_by(slug: 'web')
-
end
-
end
-
end
-
end
-
1
class HeadlessModeHandler
-
1
def initialize(app)
-
1
@app = app
-
end
-
-
1
def call(env)
-
request = ActionDispatch::Request.new(env)
-
-
# Check if headless mode is enabled
-
headless_enabled = SiteSetting.get('headless_mode', false)
-
-
then: 0
else: 0
if headless_enabled && is_frontend_route?(request)
-
return render_headless_error(request)
-
end
-
-
@app.call(env)
-
end
-
-
1
private
-
-
1
def is_frontend_route?(request)
-
path = request.path
-
-
# Exclude admin, API, auth, and asset routes
-
then: 0
else: 0
return false if path.start_with?('/admin')
-
then: 0
else: 0
return false if path.start_with?('/api')
-
then: 0
else: 0
return false if path.start_with?('/auth')
-
then: 0
else: 0
return false if path.start_with?('/themes')
-
then: 0
else: 0
return false if path.start_with?('/graphql')
-
then: 0
else: 0
return false if path.start_with?('/assets')
-
then: 0
else: 0
return false if path.start_with?('/rails')
-
then: 0
else: 0
return false if path.start_with?('/__')
-
then: 0
else: 0
return false if path == '/up' # Health check
-
then: 0
else: 0
return false if path == '/csp-violation-report'
-
-
# These are frontend routes
-
true
-
end
-
-
1
def render_headless_error(request)
-
# Try to use theme's error.liquid template
-
begin
-
renderer = LiquidTemplateRenderer.new(SiteSetting.get('active_theme', 'nordic'))
-
html = renderer.render('headless', {
-
'site' => {
-
'name' => SiteSetting.get('site_title', 'RailsPress')
-
},
-
'request_path' => request.path
-
}, 'error')
-
rescue
-
# Fallback to simple HTML
-
html = render_simple_headless_error(request)
-
end
-
-
[
-
503,
-
{
-
'Content-Type' => 'text/html',
-
'Content-Length' => html.bytesize.to_s
-
},
-
[html]
-
]
-
end
-
-
1
def render_simple_headless_error(request)
-
<<~HTML
-
<!DOCTYPE html>
-
<html lang="en">
-
<head>
-
<meta charset="UTF-8">
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<title>Headless Mode Enabled</title>
-
<style>
-
body {
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-
display: flex;
-
align-items: center;
-
justify-content: center;
-
min-height: 100vh;
-
margin: 0;
-
background: #f5f5f5;
-
color: #333;
-
}
-
.container {
-
max-width: 600px;
-
padding: 40px;
-
background: white;
-
border-radius: 12px;
-
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
-
text-align: center;
-
}
-
h1 {
-
font-size: 2.5rem;
-
margin: 0 0 16px;
-
color: #0E7C86;
-
}
-
p {
-
font-size: 1.125rem;
-
line-height: 1.6;
-
margin: 16px 0;
-
color: #666;
-
}
-
code {
-
background: #f5f5f5;
-
padding: 2px 6px;
-
border-radius: 4px;
-
font-family: 'Monaco', 'Courier New', monospace;
-
}
-
.endpoints {
-
margin: 24px 0;
-
text-align: left;
-
background: #f9f9f9;
-
padding: 20px;
-
border-radius: 8px;
-
}
-
.endpoints h3 {
-
margin: 0 0 12px;
-
font-size: 1.25rem;
-
}
-
.endpoints ul {
-
list-style: none;
-
padding: 0;
-
margin: 0;
-
}
-
.endpoints li {
-
padding: 8px 0;
-
border-bottom: 1px solid #eee;
-
}
-
.endpoints li:last-child {
-
border-bottom: none;
-
}
-
a {
-
color: #0E7C86;
-
text-decoration: none;
-
}
-
a:hover {
-
text-decoration: underline;
-
}
-
</style>
-
</head>
-
<body>
-
<div class="container">
-
<h1>🚀 Headless Mode</h1>
-
<p>This RailsPress installation is running in <strong>Headless CMS mode</strong>.</p>
-
<p>The frontend is disabled. Access your content via our powerful APIs:</p>
-
-
<div class="endpoints">
-
<h3>📡 Available APIs</h3>
-
<ul>
-
<li><strong>GraphQL:</strong> <code>POST #{request.base_url}/graphql</code></li>
-
<li><strong>REST API:</strong> <code>#{request.base_url}/api/v1</code></li>
-
<li><strong>GraphiQL Explorer:</strong> <a href="#{request.base_url}/graphiql">#{request.base_url}/graphiql</a></li>
-
</ul>
-
</div>
-
-
<p style="margin-top: 32px;">
-
<strong>Need to access the admin panel?</strong><br>
-
<a href="#{request.base_url}/admin">Go to Admin Panel →</a>
-
</p>
-
</div>
-
</body>
-
</html>
-
HTML
-
end
-
end
-
-
-
-
-
-
1
class RedirectHandler
-
1
def initialize(app)
-
1
@app = app
-
end
-
-
1
def call(env)
-
request = Rack::Request.new(env)
-
-
# Skip redirect handling for:
-
# - Admin requests
-
# - API requests
-
# - Asset requests
-
# - Healthcheck requests
-
then: 0
else: 0
return @app.call(env) if skip_redirect?(request)
-
-
# Check for matching redirect
-
redirect = find_redirect_for_path(request.path)
-
-
if redirect
-
then: 0
# Record the hit
-
redirect.record_hit! rescue nil
-
-
# Get the destination
-
destination = redirect.destination_for(request.path)
-
-
# Preserve query string
-
then: 0
else: 0
if request.query_string.present?
-
destination += "?#{request.query_string}"
-
end
-
-
# Return redirect response
-
status = redirect.http_status_code
-
headers = {
-
'Location' => destination,
-
'Content-Type' => 'text/html',
-
'Content-Length' => '0'
-
}
-
-
# Add cache headers for permanent redirects
-
then: 0
if redirect.permanent?
-
headers['Cache-Control'] = 'max-age=31536000, public'
-
else: 0
else
-
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
-
end
-
-
body = ['']
-
-
[status, headers, body]
-
else
-
else: 0
# No redirect found, continue to app
-
@app.call(env)
-
end
-
end
-
-
1
private
-
-
1
def skip_redirect?(request)
-
path = request.path
-
-
# Skip admin paths
-
then: 0
else: 0
return true if path.start_with?('/admin')
-
-
# Skip API paths
-
then: 0
else: 0
return true if path.start_with?('/api')
-
-
# Skip asset paths
-
then: 0
else: 0
return true if path.start_with?('/assets', '/packs', '/uploads')
-
-
# Skip Rails paths
-
then: 0
else: 0
return true if path.start_with?('/rails')
-
-
# Skip cable/action_cable
-
then: 0
else: 0
return true if path.start_with?('/cable')
-
-
# Skip healthcheck
-
then: 0
else: 0
return true if path == '/up'
-
-
# Skip if already redirecting (prevent loops)
-
then: 0
else: 0
return true if request.env['HTTP_X_REDIRECTED'] == 'true'
-
-
false
-
end
-
-
1
def find_redirect_for_path(path)
-
# Normalize path
-
then: 0
else: 0
path = path.chomp('/') if path.length > 1
-
-
# Try to find exact match first
-
redirect = Redirect.active.find_by(from_path: path)
-
then: 0
else: 0
return redirect if redirect
-
-
# Check for wildcard matches
-
Redirect.active.each do |redirect|
-
then: 0
else: 0
return redirect if redirect.matches?(path)
-
end
-
-
nil
-
end
-
end
-
-
-
-
-
-
-
-
-
class AdminNotification < ApplicationRecord
-
end
-
class AiAgent < ApplicationRecord
-
belongs_to :ai_provider
-
has_many :ai_usages, dependent: :destroy
-
-
# Meta fields for plugin extensibility
-
has_many :meta_fields, as: :metable, dependent: :destroy
-
include Metable
-
-
AGENT_TYPES = %w[content_summarizer post_writer comments_analyzer seo_analyzer].freeze
-
-
validates :name, presence: true
-
validates :agent_type, presence: true, inclusion: { in: AGENT_TYPES }
-
validates :ai_provider, presence: true
-
-
scope :active, -> { where(active: true) }
-
scope :by_type, ->(type) { where(agent_type: type) }
-
scope :ordered, -> { order(:position, :name) }
-
-
after_initialize :set_defaults, if: :new_record?
-
-
def full_prompt(user_input = "", context = {})
-
parts = []
-
-
# Master prompt (highest priority)
-
parts << master_prompt if master_prompt.present?
-
-
# Agent prompt
-
parts << prompt if prompt.present?
-
-
# Content guidelines
-
parts << "Content Guidelines:\n#{content}" if content.present?
-
-
# Guidelines
-
parts << "Guidelines:\n#{guidelines}" if guidelines.present?
-
-
# Rules
-
parts << "Rules:\n#{rules}" if rules.present?
-
-
# Tasks
-
parts << "Tasks:\n#{tasks}" if tasks.present?
-
-
# User input
-
parts << "User Input: #{user_input}" if user_input.present?
-
-
# Context
-
if context.present?
-
context_str = context.map { |k, v| "#{k}: #{v}" }.join("\n")
-
parts << "Context:\n#{context_str}"
-
end
-
-
parts.join("\n\n")
-
end
-
-
def execute(user_input = "", context = {}, user = nil)
-
start_time = Time.current
-
prompt_text = full_prompt(user_input, context)
-
executing_user = user || User.first # Fallback to first user if no user provided
-
-
begin
-
result = AiService.new(ai_provider).generate(prompt_text)
-
response_time = Time.current - start_time
-
-
# Log successful usage
-
ai_usages.create!(
-
user: executing_user,
-
prompt: prompt_text,
-
response: result.to_s,
-
tokens_used: calculate_tokens(prompt_text, result),
-
cost: calculate_cost(prompt_text, result),
-
response_time: response_time,
-
success: true,
-
metadata: {
-
user_input: user_input,
-
context: context,
-
agent_type: agent_type
-
}
-
)
-
-
result
-
rescue => e
-
response_time = Time.current - start_time
-
-
# Log failed usage
-
ai_usages.create!(
-
user: executing_user,
-
prompt: prompt_text,
-
response: nil,
-
tokens_used: calculate_tokens(prompt_text, ""),
-
cost: 0.0,
-
response_time: response_time,
-
success: false,
-
error_message: e.message,
-
metadata: {
-
user_input: user_input,
-
context: context,
-
agent_type: agent_type,
-
error_class: e.class.name
-
}
-
)
-
-
raise e
-
end
-
end
-
-
# Usage statistics methods
-
def total_requests
-
ai_usages.count
-
end
-
-
def total_tokens
-
ai_usages.sum(:tokens_used)
-
end
-
-
def total_cost
-
ai_usages.sum(:cost)
-
end
-
-
def requests_today
-
ai_usages.today.count
-
end
-
-
def requests_this_month
-
ai_usages.this_month.count
-
end
-
-
def average_response_time
-
ai_usages.average(:response_time)&.round(2) || 0
-
end
-
-
def success_rate
-
return 0 if ai_usages.empty?
-
(ai_usages.successful.count.to_f / ai_usages.count * 100).round(1)
-
end
-
-
def last_used
-
ai_usages.order(:created_at).last&.created_at
-
end
-
-
private
-
-
def set_defaults
-
self.active = true if active.nil?
-
self.position = 0 if position.nil?
-
end
-
-
def calculate_tokens(prompt, response)
-
# Simple token estimation: ~4 characters per token
-
# This is a rough approximation, real implementations would use tokenizers
-
total_text = prompt.to_s + response.to_s
-
(total_text.length / 4.0).ceil
-
end
-
-
def calculate_cost(prompt, response)
-
# Simple cost calculation based on tokens
-
# This should be replaced with actual pricing from the AI provider
-
tokens = calculate_tokens(prompt, response)
-
case ai_provider.provider_type
-
when 'openai'
-
tokens * 0.00002 # Rough estimate for GPT-3.5
-
when 'anthropic'
-
tokens * 0.000015 # Rough estimate for Claude
-
else
-
tokens * 0.00001 # Default estimate
-
end
-
end
-
end
-
class AiProvider < ApplicationRecord
-
has_many :ai_agents, dependent: :destroy
-
-
PROVIDER_TYPES = %w[openai cohere anthropic google].freeze
-
-
validates :name, presence: true
-
validates :provider_type, presence: true, inclusion: { in: PROVIDER_TYPES }
-
validates :api_key, presence: true
-
validates :model_identifier, presence: true
-
validates :max_tokens, presence: true, numericality: { greater_than: 0 }
-
validates :temperature, presence: true, numericality: { in: 0.0..2.0 }
-
-
scope :active, -> { where(active: true) }
-
scope :by_type, ->(type) { where(provider_type: type) }
-
scope :ordered, -> { order(:position, :name) }
-
-
after_initialize :set_defaults, if: :new_record?
-
-
def display_name
-
"#{name} (#{provider_type.titleize})"
-
end
-
-
def latest_model_for_type
-
case provider_type
-
when 'openai'
-
'gpt-4o'
-
when 'cohere'
-
'command-r-plus'
-
when 'anthropic'
-
'claude-3-5-sonnet-20241022'
-
when 'google'
-
'gemini-1.5-pro'
-
else
-
model_identifier
-
end
-
end
-
-
private
-
-
def set_defaults
-
self.active = true if active.nil?
-
self.temperature = 0.7 if temperature.nil?
-
self.max_tokens = 4000 if max_tokens.nil?
-
self.position = 0 if position.nil?
-
end
-
end
-
class AiUsage < ApplicationRecord
-
belongs_to :ai_agent
-
belongs_to :user
-
-
validates :prompt, presence: true
-
validates :tokens_used, presence: true, numericality: { greater_than: 0 }
-
validates :cost, presence: true, numericality: { greater_than_or_equal_to: 0 }
-
validates :response_time, presence: true, numericality: { greater_than: 0 }
-
validates :success, inclusion: { in: [true, false] }
-
-
scope :successful, -> { where(success: true) }
-
scope :failed, -> { where(success: false) }
-
scope :today, -> { where(created_at: Date.current.beginning_of_day..Date.current.end_of_day) }
-
scope :this_month, -> { where(created_at: Date.current.beginning_of_month..Date.current.end_of_month) }
-
scope :by_agent, ->(agent) { where(ai_agent: agent) }
-
-
def self.total_tokens_for_period(start_date, end_date)
-
where(created_at: start_date..end_date).sum(:tokens_used)
-
end
-
-
def self.total_cost_for_period(start_date, end_date)
-
where(created_at: start_date..end_date).sum(:cost)
-
end
-
-
def self.average_response_time_for_period(start_date, end_date)
-
where(created_at: start_date..end_date).average(:response_time)&.round(2)
-
end
-
-
def self.success_rate_for_period(start_date, end_date)
-
period_usages = where(created_at: start_date..end_date)
-
return 0 if period_usages.empty?
-
-
(period_usages.successful.count.to_f / period_usages.count * 100).round(1)
-
end
-
end
-
# frozen_string_literal: true
-
-
class AnalyticsAuditLog < ApplicationRecord
-
acts_as_tenant(:tenant, optional: true)
-
-
belongs_to :user, optional: true
-
belongs_to :admin_user, class_name: 'User', optional: true
-
-
validates :data_type, presence: true
-
validates :action, presence: true
-
validates :timestamp, presence: true
-
-
scope :recent, -> { where('timestamp > ?', 30.days.ago) }
-
scope :by_user, ->(user_id) { where(user_id: user_id) }
-
scope :by_action, ->(action) { where(action: action) }
-
scope :by_data_type, ->(data_type) { where(data_type: data_type) }
-
-
def self.log_access(user_id, data_type, action, admin_user = nil)
-
create!(
-
user_id: user_id,
-
data_type: data_type,
-
action: action,
-
admin_user: admin_user,
-
timestamp: Time.current,
-
ip_address: AnalyticsSecurityService.anonymize_ip(get_current_ip),
-
user_agent: get_current_user_agent
-
)
-
end
-
-
private
-
-
def self.get_current_ip
-
Thread.current[:current_request]&.remote_ip || '127.0.0.1'
-
end
-
-
def self.get_current_user_agent
-
Thread.current[:current_request]&.user_agent || 'Unknown'
-
end
-
end
-
# frozen_string_literal: true
-
-
class AnalyticsConsent < ApplicationRecord
-
acts_as_tenant(:tenant, optional: true)
-
-
belongs_to :user, optional: true
-
-
validates :consent_type, presence: true
-
validates :granted, inclusion: { in: [true, false] }
-
validates :timestamp, presence: true
-
-
scope :granted, -> { where(granted: true) }
-
scope :denied, -> { where(granted: false) }
-
scope :by_type, ->(type) { where(consent_type: type) }
-
scope :recent, -> { where('timestamp > ?', 1.year.ago) }
-
scope :by_purpose, ->(purpose) { where(purpose: purpose) }
-
-
def self.get_user_consent(user_id, consent_type)
-
recent
-
.where(user_id: user_id, consent_type: consent_type)
-
.order(timestamp: :desc)
-
.first
-
end
-
-
def self.user_has_consent?(user_id, consent_type)
-
consent = get_user_consent(user_id, consent_type)
-
consent&.granted || false
-
end
-
-
def self.consent_rate(consent_type, period = 30.days)
-
total_consents = where(consent_type: consent_type, timestamp: period.ago..Time.current).count
-
granted_consents = where(consent_type: consent_type, granted: true, timestamp: period.ago..Time.current).count
-
-
return 0 if total_consents.zero?
-
(granted_consents.to_f / total_consents * 100).round(2)
-
end
-
end
-
# frozen_string_literal: true
-
-
class AnalyticsDataDeletion < ApplicationRecord
-
acts_as_tenant(:tenant, optional: true)
-
-
belongs_to :user, optional: true
-
belongs_to :admin_user, class_name: 'User', optional: true
-
-
validates :data_types, presence: true
-
validates :timestamp, presence: true
-
-
scope :recent, -> { where('timestamp > ?', 1.year.ago) }
-
scope :by_user, ->(user_id) { where(user_id: user_id) }
-
scope :by_admin, ->(admin_id) { where(admin_user_id: admin_id) }
-
-
def self.log_deletion(user_id, data_types, admin_user = nil)
-
create!(
-
user_id: user_id,
-
data_types: data_types,
-
admin_user: admin_user,
-
timestamp: Time.current
-
)
-
end
-
-
def data_types_array
-
case data_types
-
when String
-
JSON.parse(data_types) rescue [data_types]
-
when Array
-
data_types
-
else
-
[data_types]
-
end
-
end
-
end
-
class AnalyticsEvent < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant, optional: true)
-
-
# Associations
-
belongs_to :user, optional: true
-
belongs_to :tenant, optional: true
-
-
# Serialization
-
serialize :properties, coder: JSON, type: Hash
-
-
# Validations
-
validates :event_name, presence: true
-
validates :session_id, presence: true
-
-
# Scopes
-
scope :by_event_name, ->(name) { where(event_name: name) }
-
scope :by_session, ->(session_id) { where(session_id: session_id) }
-
scope :by_user, ->(user_id) { where(user_id: user_id) }
-
scope :recent, -> { order(created_at: :desc) }
-
scope :today, -> { where('created_at >= ?', Time.current.beginning_of_day) }
-
scope :this_week, -> { where('created_at >= ?', 1.week.ago) }
-
scope :this_month, -> { where('created_at >= ?', 1.month.ago) }
-
-
# Class methods for event analytics
-
-
def self.event_stats(period: :month)
-
range = case period.to_sym
-
when :today
-
Time.current.beginning_of_day..Time.current.end_of_day
-
when :week
-
1.week.ago..Time.current
-
when :month
-
1.month.ago..Time.current
-
when :year
-
1.year.ago..Time.current
-
else
-
1.month.ago..Time.current
-
end
-
-
events = where(created_at: range)
-
-
{
-
total_events: events.count,
-
unique_sessions: events.distinct.count(:session_id),
-
top_events: events.group(:event_name).order('count_id DESC').limit(10).count(:id),
-
events_per_session: events.count.to_f / events.distinct.count(:session_id),
-
conversion_events: events.where(event_name: ['purchase', 'signup', 'download', 'contact']).count
-
}
-
end
-
-
def self.track_conversion(event_name, properties = {})
-
# Track conversion events with enhanced properties
-
create!(
-
event_name: event_name,
-
properties: properties.merge({
-
conversion: true,
-
timestamp: Time.current.iso8601,
-
user_agent: properties[:user_agent],
-
referrer: properties[:referrer]
-
}),
-
session_id: properties[:session_id] || SecureRandom.hex(16),
-
user_id: properties[:user_id],
-
path: properties[:path] || '/',
-
tenant: properties[:tenant] || ActsAsTenant.current_tenant
-
)
-
rescue => e
-
Rails.logger.error "Failed to track conversion event: #{e.message}"
-
nil
-
end
-
end
-
class ApiToken < ApplicationRecord
-
belongs_to :user
-
-
# Roles
-
ROLES = %w[public editor admin].freeze
-
-
# Default permissions by role
-
DEFAULT_PERMISSIONS = {
-
'public' => {
-
'posts' => ['read'],
-
'pages' => ['read'],
-
'categories' => ['read'],
-
'tags' => ['read'],
-
'comments' => ['read']
-
},
-
'editor' => {
-
'posts' => ['read', 'create', 'update'],
-
'pages' => ['read', 'create', 'update'],
-
'categories' => ['read', 'create', 'update'],
-
'tags' => ['read', 'create', 'update'],
-
'comments' => ['read', 'create', 'update', 'delete'],
-
'media' => ['read', 'create', 'update', 'delete']
-
},
-
'admin' => {
-
'posts' => ['read', 'create', 'update', 'delete'],
-
'pages' => ['read', 'create', 'update', 'delete'],
-
'categories' => ['read', 'create', 'update', 'delete'],
-
'tags' => ['read', 'create', 'update', 'delete'],
-
'comments' => ['read', 'create', 'update', 'delete'],
-
'media' => ['read', 'create', 'update', 'delete'],
-
'users' => ['read', 'create', 'update', 'delete'],
-
'settings' => ['read', 'update'],
-
'ai_agents' => ['read', 'execute', 'create', 'update', 'delete'],
-
'ai_providers' => ['read', 'create', 'update', 'delete']
-
}
-
}.freeze
-
-
# Validations
-
validates :name, presence: true, uniqueness: { scope: :user_id }
-
validates :token, presence: true, uniqueness: true
-
validates :role, presence: true, inclusion: { in: ROLES }
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :expired, -> { where('expires_at < ?', Time.current) }
-
scope :not_expired, -> { where('expires_at IS NULL OR expires_at > ?', Time.current) }
-
scope :by_role, ->(role) { where(role: role) }
-
scope :recent, -> { order(created_at: :desc) }
-
-
# Callbacks
-
before_validation :generate_token, on: :create
-
before_validation :set_default_permissions, on: :create
-
-
# Instance methods
-
def expired?
-
expires_at.present? && expires_at < Time.current
-
end
-
-
def valid_token?
-
active && !expired?
-
end
-
-
def touch_last_used!
-
update_column(:last_used_at, Time.current)
-
end
-
-
def has_permission?(resource, action)
-
return false unless valid_token?
-
-
resource_permissions = permissions[resource.to_s] || []
-
resource_permissions.include?(action.to_s)
-
end
-
-
def masked_token
-
return nil unless token
-
"#{token[0..7]}...#{token[-4..-1]}"
-
end
-
-
def display_role
-
role.titleize
-
end
-
-
private
-
-
def generate_token
-
self.token ||= SecureRandom.base58(32)
-
end
-
-
def set_default_permissions
-
self.permissions ||= DEFAULT_PERMISSIONS[role] || DEFAULT_PERMISSIONS['public']
-
end
-
end
-
1
class ApplicationRecord < ActiveRecord::Base
-
1
primary_abstract_class
-
end
-
class ArchivedAnalyticsEvent < ApplicationRecord
-
# Archived analytics events for long-term storage
-
# This model stores historical event data that has been archived
-
-
acts_as_tenant(:tenant, optional: true)
-
-
belongs_to :tenant, optional: true
-
belongs_to :user, optional: true
-
-
# Serialize properties as JSON
-
serialize :properties, JSON
-
-
# Scopes for filtering archived events
-
scope :by_event_name, ->(event_name) { where(event_name: event_name) }
-
scope :by_date_range, ->(start_date, end_date) { where(created_at: start_date..end_date) }
-
scope :by_session, ->(session_id) { where(session_id: session_id) }
-
scope :by_user, ->(user_id) { where(user_id: user_id) }
-
scope :recent, -> { order(created_at: :desc) }
-
scope :today, -> { where(created_at: Date.current.all_day) }
-
scope :this_week, -> { where(created_at: Date.current.beginning_of_week..Date.current.end_of_week) }
-
scope :this_month, -> { where(created_at: Date.current.beginning_of_month..Date.current.end_of_month) }
-
-
# Event statistics for archived data
-
def self.event_stats_by_date_range(start_date, end_date)
-
data = by_date_range(start_date, end_date)
-
-
{
-
total_events: data.count,
-
unique_events: data.distinct.count(:event_name),
-
top_events: data.group(:event_name).count.sort_by { |_, count| -count }.first(20),
-
events_by_hour: data.group("strftime('%H', created_at)").count,
-
events_by_day: data.group("date(created_at)").count,
-
conversion_events: data.where(event_name: ['conversion', 'purchase', 'signup', 'download']).count
-
}
-
end
-
-
def self.export_for_analysis(start_date, end_date)
-
by_date_range(start_date, end_date).map do |event|
-
{
-
date: event.created_at.strftime('%Y-%m-%d'),
-
hour: event.created_at.hour,
-
event_name: event.event_name,
-
properties: event.properties,
-
session_id: event.session_id,
-
user_id: event.user_id
-
}
-
end
-
end
-
end
-
class ArchivedPageview < ApplicationRecord
-
# Archived pageview data for long-term storage
-
# This model stores historical analytics data that has been archived
-
-
acts_as_tenant(:tenant, optional: true)
-
-
belongs_to :tenant, optional: true
-
-
# Serialize metadata as JSON
-
serialize :metadata, JSON
-
-
# Scopes for filtering archived data
-
scope :by_date_range, ->(start_date, end_date) { where(visited_at: start_date..end_date) }
-
scope :by_country, ->(country_code) { where(country_code: country_code) }
-
scope :by_device, ->(device) { where(device: device) }
-
scope :by_browser, ->(browser) { where(browser: browser) }
-
scope :unique_visitors, -> { where(unique_visitor: true) }
-
scope :returning_visitors, -> { where(returning_visitor: true) }
-
scope :bots, -> { where(bot: true) }
-
scope :consented, -> { where(consented: true) }
-
-
# Statistics methods for archived data
-
def self.stats_by_date_range(start_date, end_date)
-
data = by_date_range(start_date, end_date)
-
-
{
-
total_pageviews: data.count,
-
unique_visitors: data.unique_visitors.count,
-
returning_visitors: data.returning_visitors.count,
-
bots: data.bots.count,
-
consented_views: data.consented.count,
-
average_reading_time: data.average(:reading_time),
-
average_scroll_depth: data.average(:scroll_depth),
-
average_completion_rate: data.average(:completion_rate),
-
top_countries: data.group(:country_name).count.sort_by { |_, count| -count }.first(10),
-
top_devices: data.group(:device).count.sort_by { |_, count| -count }.first(10),
-
top_browsers: data.group(:browser).count.sort_by { |_, count| -count }.first(10),
-
top_pages: data.group(:path).count.sort_by { |_, count| -count }.first(20)
-
}
-
end
-
-
def self.export_for_analysis(start_date, end_date)
-
by_date_range(start_date, end_date).map do |pv|
-
{
-
date: pv.visited_at.strftime('%Y-%m-%d'),
-
hour: pv.visited_at.hour,
-
path: pv.path,
-
title: pv.title,
-
country: pv.country_name,
-
device: pv.device,
-
browser: pv.browser,
-
unique_visitor: pv.unique_visitor,
-
reading_time: pv.reading_time,
-
scroll_depth: pv.scroll_depth,
-
completion_rate: pv.completion_rate
-
}
-
end
-
end
-
end
-
class BuilderPage < ApplicationRecord
-
# Associations
-
belongs_to :tenant
-
belongs_to :builder_theme
-
has_many :builder_page_sections, -> { ordered }, dependent: :destroy
-
-
# Serialization
-
serialize :settings, coder: JSON, type: Hash
-
serialize :sections, coder: JSON, type: Hash
-
-
# Validations
-
validates :template_name, presence: true, uniqueness: { scope: :builder_theme_id }
-
validates :page_title, presence: true
-
validates :tenant, presence: true
-
validates :builder_theme, presence: true
-
-
# Scopes
-
scope :ordered, -> { order(:position) }
-
scope :published, -> { where(published: true) }
-
scope :by_template, ->(template) { where(template_name: template) }
-
-
# Callbacks
-
before_validation :set_defaults, on: :create
-
-
# Class methods
-
def self.create_page(builder_theme, template_name, page_title, settings = {}, sections = {})
-
position = builder_theme.builder_pages.count
-
-
create!(
-
builder_theme: builder_theme,
-
tenant: builder_theme.tenant,
-
template_name: template_name,
-
page_title: page_title,
-
settings: settings,
-
sections: sections,
-
position: position
-
)
-
end
-
-
def self.initialize_default_pages(builder_theme)
-
default_pages = [
-
{ template: 'index', title: 'Home', sections: { 'header' => {}, 'hero' => {}, 'footer' => {} } },
-
{ template: 'blog', title: 'Blog', sections: { 'header' => {}, 'post-list' => {}, 'footer' => {} } },
-
{ template: 'post', title: 'Post', sections: { 'header' => {}, 'post-content' => {}, 'comments' => {}, 'footer' => {} } },
-
{ template: 'page', title: 'Page', sections: { 'header' => {}, 'rich-text' => {}, 'footer' => {} } },
-
{ template: 'search', title: 'Search', sections: { 'header' => {}, 'search-form' => {}, 'search-results' => {}, 'footer' => {} } }
-
]
-
-
default_pages.each do |page_data|
-
next if builder_theme.builder_pages.exists?(template_name: page_data[:template])
-
-
create_page(
-
builder_theme,
-
page_data[:template],
-
page_data[:title],
-
{},
-
page_data[:sections]
-
)
-
end
-
end
-
-
# Instance methods
-
def display_name
-
page_title
-
end
-
-
def description
-
"#{template_name.humanize} page with #{sections.keys.size} sections"
-
end
-
-
def section_order
-
sections.keys
-
end
-
-
def get_setting(key, default = nil)
-
settings[key.to_s] || default
-
end
-
-
def set_setting(key, value)
-
self.settings = settings.merge(key.to_s => value)
-
save!
-
end
-
-
def get_section_settings(section_id)
-
sections[section_id.to_s] || {}
-
end
-
-
def set_section_settings(section_id, section_settings)
-
self.sections = sections.merge(section_id.to_s => section_settings)
-
save!
-
end
-
-
def add_section(section_id, section_settings = {})
-
self.sections = sections.merge(section_id.to_s => section_settings)
-
save!
-
end
-
-
def remove_section(section_id)
-
self.sections = sections.except(section_id.to_s)
-
save!
-
end
-
-
def reorder_sections(section_ids)
-
new_sections = {}
-
section_ids.each do |section_id|
-
new_sections[section_id] = sections[section_id] if sections.key?(section_id)
-
end
-
self.sections = new_sections
-
save!
-
end
-
-
def sections_data
-
sections.map do |section_id, section_settings|
-
{
-
'id' => section_id,
-
'type' => section_id,
-
'settings' => section_settings
-
}
-
end
-
end
-
-
def template_file_path
-
"templates/#{template_name}.json"
-
end
-
-
def template_content
-
# Get template content from filesystem
-
theme_path = Rails.root.join('app', 'themes', builder_theme.theme_name.underscore)
-
template_file = theme_path.join(template_file_path)
-
-
if File.exist?(template_file)
-
JSON.parse(File.read(template_file))
-
else
-
# Default template structure
-
{
-
'sections' => sections,
-
'order' => section_order,
-
'settings' => settings
-
}
-
end
-
end
-
-
def publish!
-
update!(published: true)
-
end
-
-
def unpublish!
-
update!(published: false)
-
end
-
-
private
-
-
def set_defaults
-
self.settings ||= {}
-
self.sections ||= {}
-
self.position ||= 0
-
self.published ||= false
-
end
-
end
-
class BuilderPageSection < ApplicationRecord
-
# Associations
-
belongs_to :tenant
-
belongs_to :builder_page
-
-
# Serialization
-
serialize :settings, coder: JSON, type: Hash
-
-
# Validations
-
validates :section_id, presence: true, uniqueness: { scope: :builder_page_id }
-
validates :section_type, presence: true
-
validates :position, presence: true, numericality: { greater_than_or_equal_to: 0 }
-
validates :settings, presence: true
-
validates :tenant, presence: true
-
validates :builder_page, presence: true
-
-
# Scopes
-
scope :ordered, -> { order(:position) }
-
scope :by_type, ->(type) { where(section_type: type) }
-
-
# Callbacks
-
before_validation :set_defaults, on: :create
-
-
# Class methods
-
def self.create_section(builder_page, section_type, settings = {})
-
section_id = "#{section_type}_#{Time.current.to_i}"
-
position = builder_page.builder_page_sections.count
-
-
create!(
-
builder_page: builder_page,
-
tenant: builder_page.tenant,
-
section_id: section_id,
-
section_type: section_type,
-
settings: settings,
-
position: position
-
)
-
end
-
-
def self.reorder_sections(builder_page, section_ids)
-
section_ids.each_with_index do |section_id, index|
-
section = builder_page.builder_page_sections.find_by(section_id: section_id)
-
section&.update!(position: index)
-
end
-
end
-
-
# Instance methods
-
def update_settings!(new_settings)
-
update!(settings: settings.merge(new_settings.stringify_keys))
-
end
-
-
def get_setting(key, default = nil)
-
settings[key.to_s] || default
-
end
-
-
def set_setting(key, value)
-
self.settings = settings.merge(key.to_s => value)
-
save!
-
end
-
-
def display_name
-
case section_type
-
when 'hero'
-
'Hero'
-
when 'post-list'
-
'Blog List'
-
when 'rich-text'
-
'Rich Text'
-
when 'image'
-
'Image'
-
when 'gallery'
-
'Image Gallery'
-
when 'contact'
-
'Contact Form'
-
when 'header'
-
'Header'
-
when 'footer'
-
'Footer'
-
when 'menu'
-
'Menu'
-
when 'search-form'
-
'Search Form'
-
when 'comments'
-
'Comments'
-
when 'pagination'
-
'Pagination'
-
when 'taxonomy-list'
-
'Category/Tag List'
-
when 'seo-head'
-
'SEO Head'
-
when 'post-content'
-
'Post Content'
-
when 'related-posts'
-
'Related Posts'
-
else
-
section_type.humanize
-
end
-
end
-
-
def description
-
case section_type
-
when 'hero'
-
get_setting('heading', 'Hero section')
-
when 'post-list'
-
"Blog list (#{get_setting('items_per_page', 6)} items)"
-
when 'rich-text'
-
get_setting('content', 'Rich text content')&.truncate(50)
-
when 'image'
-
get_setting('alt_text', 'Image section')
-
when 'contact'
-
get_setting('title', 'Contact form')
-
else
-
"#{display_name} section"
-
end
-
end
-
-
def section_content
-
# Get section content from filesystem
-
theme_path = Rails.root.join('app', 'themes', builder_page.builder_theme.theme_name.underscore)
-
section_file = theme_path.join('sections', "#{section_type}.liquid")
-
-
if File.exist?(section_file)
-
File.read(section_file)
-
else
-
''
-
end
-
end
-
-
private
-
-
def set_defaults
-
self.settings ||= {}
-
self.position ||= 0
-
end
-
end
-
-
class BuilderTheme < ApplicationRecord
-
# Associations
-
belongs_to :tenant
-
belongs_to :user
-
belongs_to :parent_version, class_name: 'BuilderTheme', optional: true
-
has_many :child_versions, class_name: 'BuilderTheme', foreign_key: 'parent_version_id', dependent: :nullify
-
has_many :builder_theme_files, dependent: :destroy
-
has_many :builder_theme_sections, -> { ordered }, dependent: :destroy
-
has_many :builder_pages, -> { ordered }, dependent: :destroy
-
has_many :builder_theme_snapshots, dependent: :destroy
-
has_many :theme_previews, dependent: :destroy
-
has_many :theme_preview_files, dependent: :destroy
-
-
# Serialization
-
serialize :settings_data, coder: JSON, type: Hash
-
-
# Validations
-
validates :theme_name, presence: true
-
validates :label, presence: true
-
validates :checksum, presence: true, uniqueness: true
-
validates :user, presence: true
-
-
# Scopes
-
scope :published, -> { where(published: true) }
-
scope :drafts, -> { where(published: false) }
-
scope :for_theme, ->(theme_name) { where(theme_name: theme_name) }
-
scope :latest, -> { order(created_at: :desc) }
-
-
# Callbacks
-
before_validation :generate_checksum, on: :create
-
after_create :initialize_default_pages
-
-
# Instance methods
-
def theme
-
@theme ||= Theme.where("LOWER(name) = ?", theme_name.downcase).first
-
end
-
-
def has_published_version?
-
@has_published_version ||= PublishedThemeVersion.for_theme(theme).exists?
-
end
-
-
def published_version
-
@published_version ||= PublishedThemeVersion.for_theme(theme).latest.first
-
end
-
-
def is_theme_active?
-
# Check if the theme this BuilderTheme belongs to is active
-
theme&.active?
-
end
-
-
# Class methods
-
def self.create_version(theme_name, user, parent_version = nil, label = nil)
-
label ||= "Version #{Time.current.strftime('%Y%m%d_%H%M%S')}"
-
-
# Get the actual tenant object
-
current_tenant = ActsAsTenant.current_tenant
-
tenant = if current_tenant.is_a?(OpenStruct)
-
Tenant.find(current_tenant.id)
-
else
-
current_tenant
-
end
-
-
create!(
-
theme_name: theme_name,
-
label: label,
-
parent_version: parent_version,
-
user: user,
-
tenant: tenant,
-
summary: "Created new version from #{parent_version&.label || 'base'}"
-
)
-
end
-
-
def self.current_for_theme(theme_name)
-
published.for_theme(theme_name).latest.first
-
end
-
-
def self.draft_for_theme(theme_name)
-
drafts.for_theme(theme_name).latest.first
-
end
-
-
# Instance methods
-
def publish!
-
# Unpublish other versions of the same theme
-
self.class.for_theme(theme_name).where.not(id: id).update_all(published: false)
-
-
# Publish this version
-
update!(published: true)
-
-
# Create snapshot
-
create_snapshot!
-
end
-
-
def create_snapshot!
-
BuilderThemeSnapshot.create!(
-
theme_name: theme_name,
-
builder_theme: self,
-
settings_data: settings_data.to_json,
-
sections_data: sections_data.to_json,
-
user: user,
-
tenant: tenant, # tenant is already a proper Tenant object
-
checksum: Digest::SHA256.hexdigest("#{settings_data}#{sections_data}#{created_at}")
-
)
-
end
-
-
def sections_data
-
@sections_data ||= build_sections_data
-
end
-
-
def sections_data=(data)
-
@sections_data = data
-
end
-
-
def build_sections_data
-
sections = {}
-
builder_theme_sections.each do |section|
-
sections[section.section_id] = {
-
'type' => section.section_type,
-
'settings' => section.settings
-
}
-
end
-
sections
-
end
-
-
def section_order
-
builder_theme_sections.pluck(:section_id)
-
end
-
-
def add_section(section_type, settings = {})
-
BuilderThemeSection.create_section(self, section_type, settings)
-
end
-
-
def remove_section(section_id)
-
section = builder_theme_sections.find_by(section_id: section_id)
-
section&.destroy!
-
-
# Reorder remaining sections
-
reorder_sections
-
end
-
-
def reorder_sections
-
builder_theme_sections.ordered.each_with_index do |section, index|
-
section.update!(position: index)
-
end
-
end
-
-
def update_section_order(section_ids)
-
BuilderThemeSection.reorder_sections(self, section_ids)
-
end
-
-
def get_section(section_id)
-
builder_theme_sections.find_by(section_id: section_id)
-
end
-
-
# Update section settings in PublishedThemeFile
-
def update_section_settings(section_id, settings, template_name = 'index')
-
published_version = ensure_published_version!
-
-
# Get the template file
-
template_file = published_version.published_theme_files.find_by(file_path: "templates/#{template_name}.json")
-
return false unless template_file
-
-
# Parse the template content
-
template_content = JSON.parse(template_file.content)
-
-
# Update the section settings
-
if template_content['sections'] && template_content['sections'][section_id]
-
template_content['sections'][section_id]['settings'] = settings
-
-
# Update the PublishedThemeFile content
-
template_file.update!(
-
content: JSON.pretty_generate(template_content),
-
checksum: Digest::MD5.hexdigest(template_file.content)
-
)
-
-
true
-
else
-
false
-
end
-
end
-
-
# Update template file with new sections order
-
def update_template_sections(template_name, sections_hash, section_order)
-
published_version = ensure_published_version!
-
-
# Get or create the template file
-
template_file = published_version.published_theme_files.find_or_initialize_by(file_path: "templates/#{template_name}.json")
-
-
# Create the template content
-
template_content = {
-
'name' => template_name.humanize,
-
'sections' => sections_hash,
-
'order' => section_order
-
}
-
-
# Update the PublishedThemeFile content
-
template_file.assign_attributes(
-
file_type: 'template',
-
content: JSON.pretty_generate(template_content),
-
checksum: Digest::MD5.hexdigest(template_file.content)
-
)
-
-
template_file.save!
-
end
-
-
# Get rendered file - creates PublishedThemeVersion if none exists, then works with PublishedThemeFile
-
def get_rendered_file(template_name = 'index')
-
# Ensure we have a PublishedThemeVersion to work with
-
published_version = ensure_published_version!
-
-
# Get layout file from PublishedThemeFile
-
layout_file = published_version.published_theme_files.find_by(file_path: 'layout/theme.liquid')
-
layout_content = layout_file&.content || default_layout
-
-
# Get template JSON from PublishedThemeFile
-
template_file = published_version.published_theme_files.find_by(file_path: "templates/#{template_name}.json")
-
template_content = template_file ? JSON.parse(template_file.content) : {}
-
-
Rails.logger.info "Template file found: #{template_file.present?}"
-
Rails.logger.info "Template content: #{template_content.inspect}"
-
-
# Build page sections from template content
-
page_sections = build_page_sections_from_template(template_content)
-
Rails.logger.info "Built #{page_sections.length} page sections"
-
-
# Return rendered data with PublishedThemeFile content
-
{
-
template_name: template_name,
-
template_content: template_content,
-
layout_content: layout_content,
-
page_sections: page_sections,
-
theme_settings: {},
-
published_version: published_version
-
}
-
end
-
-
# Publish the builder theme as a PublishedThemeVersion
-
def publish!(publisher = nil)
-
# Find or create the latest PublishedThemeVersion
-
published_version = PublishedThemeVersion.where(theme: theme).latest.first
-
-
if published_version
-
# Update existing version
-
published_version.update!(published_at: Time.current, published_by: publisher || user)
-
else
-
# Create new version
-
published_version = PublishedThemeVersion.create!(
-
theme: theme,
-
version_number: next_version_number,
-
published_at: Time.current,
-
published_by: publisher || user,
-
tenant: tenant
-
)
-
end
-
-
# Copy all files from ThemesManager (database)
-
manager = ThemesManager.new
-
active_theme_version = manager.active_theme_version
-
-
# Copy all theme files
-
active_theme_version.theme_files.each do |theme_file|
-
# Get the original content from ThemesManager
-
content = manager.get_file(theme_file.file_path)
-
next unless content
-
-
# Create or update the published file
-
published_file = PublishedThemeFile.find_or_initialize_by(
-
published_theme_version: published_version,
-
file_path: theme_file.file_path
-
)
-
-
published_file.assign_attributes(
-
file_type: theme_file.file_type,
-
content: content,
-
checksum: Digest::MD5.hexdigest(content)
-
)
-
-
published_file.save!
-
end
-
-
# Mark as published
-
update!(published: true, published_at: Time.current)
-
-
published_version
-
end
-
-
-
def ensure_published_version!
-
# Check if we have a PublishedThemeVersion for this theme
-
published_version = PublishedThemeVersion.where(theme: theme).latest.first
-
-
unless published_version
-
Rails.logger.info "No PublishedThemeVersion found for #{theme.name}, creating initial version..."
-
-
# Create initial PublishedThemeVersion
-
published_version = PublishedThemeVersion.create!(
-
theme: theme,
-
version_number: 1,
-
published_at: Time.current,
-
published_by: user,
-
tenant: tenant
-
)
-
-
# Copy all files from ThemesManager to PublishedThemeFile
-
manager = ThemesManager.new
-
active_theme_version = manager.active_theme_version
-
-
if active_theme_version
-
active_theme_version.theme_files.each do |theme_file|
-
content = manager.get_file(theme_file.file_path)
-
next unless content
-
-
# Convert absolute path to relative path
-
relative_path = theme_file.file_path.gsub(/^.*\/themes\/[^\/]+\//, '')
-
-
PublishedThemeFile.create!(
-
published_theme_version: published_version,
-
file_path: relative_path,
-
file_type: theme_file.file_type,
-
content: content,
-
checksum: Digest::MD5.hexdigest(content)
-
)
-
end
-
-
Rails.logger.info "Created initial PublishedThemeVersion #{published_version.id} with #{published_version.published_theme_files.count} files"
-
end
-
end
-
-
published_version
-
end
-
-
private
-
-
# Build page sections from template JSON content
-
def build_page_sections_from_template(template_content)
-
sections = []
-
-
# Get the order from template content
-
section_order = template_content['order'] || []
-
-
# If no order specified, use the keys from sections
-
if section_order.empty? && template_content['sections']
-
section_order = template_content['sections'].keys
-
end
-
-
# Build section objects in the correct order
-
section_order.each do |section_id|
-
section_data = template_content['sections']&.[](section_id)
-
next unless section_data
-
-
# Create a mock section object that matches BuilderPageSection interface
-
section = OpenStruct.new(
-
section_id: section_id,
-
section_type: section_data['type'] || section_id,
-
settings: section_data['settings'] || {},
-
position: sections.length + 1,
-
display_name: section_data['name'] || section_id.humanize,
-
description: section_data['description'] || ''
-
)
-
-
sections << section
-
end
-
-
sections
-
end
-
-
def next_version_number
-
# Get the next version number for this theme
-
last_version = PublishedThemeVersion.where(theme: theme).maximum(:version_number) || 0
-
last_version + 1
-
end
-
-
# Sync sections from template JSON file to database
-
def sync_page_sections_from_template(page, template_name)
-
manager = ThemesManager.new
-
template_data = manager.get_parsed_file("templates/#{template_name}.json")
-
-
return unless template_data && template_data['sections']
-
-
# Clear existing sections
-
page.builder_page_sections.destroy_all
-
-
# Handle different section formats
-
if template_data['sections'].is_a?(Array)
-
# Array format: [["id", {type, settings}], ...]
-
template_data['sections'].each_with_index do |section_data, index|
-
# Handle both array format [id, {type, settings}] and hash format {id, type, settings}
-
if section_data.is_a?(Array) && section_data.length == 2
-
section_id = section_data[0]
-
section_config = section_data[1]
-
section_type = section_config['type']
-
section_settings = section_config['settings'] || {}
-
elsif section_data.is_a?(Hash)
-
section_id = section_data['id'] || "#{section_data['type']}_#{Time.current.to_i}"
-
section_type = section_data['type']
-
section_settings = section_data['settings'] || {}
-
else
-
next # Skip invalid section data
-
end
-
-
# Create the section
-
page.builder_page_sections.create!(
-
tenant: tenant,
-
section_id: section_id,
-
section_type: section_type,
-
settings: section_settings,
-
position: index
-
)
-
end
-
elsif template_data['sections'].is_a?(Hash) && template_data['order']
-
# Object format with order array: {sections: {id: {type, settings}}, order: ["id1", "id2"]}
-
template_data['order'].each_with_index do |section_id, index|
-
section_config = template_data['sections'][section_id]
-
next unless section_config
-
-
section_type = section_config['type']
-
section_settings = section_config['settings'] || {}
-
-
# Create the section
-
page.builder_page_sections.create!(
-
tenant: tenant,
-
section_id: section_id,
-
section_type: section_type,
-
settings: section_settings,
-
position: index
-
)
-
end
-
end
-
-
Rails.logger.info "Synced #{page.builder_page_sections.count} sections for #{template_name} template"
-
end
-
-
# Get all builder files for this theme
-
def builder_files
-
BuilderFile.where(tenant: tenant)
-
end
-
-
def settings_data
-
@settings_data ||= load_settings_data
-
end
-
-
def settings_data=(data)
-
@settings_data = data
-
end
-
-
def get_file(path)
-
builder_theme_files.find_by(path: path)
-
end
-
-
def get_template(template_name)
-
# Get the template JSON file (e.g., 'home.json', 'blog.json', etc.)
-
template_file = get_file("templates/#{template_name}.json")
-
return nil unless template_file
-
-
begin
-
JSON.parse(template_file.content)
-
rescue JSON::ParserError
-
nil
-
end
-
end
-
-
def get_section_content(section_type)
-
# Get the section Liquid file
-
section_file = get_file("sections/#{section_type}.liquid")
-
section_file&.content || ''
-
end
-
-
def get_layout_content
-
# Get the layout Liquid file
-
layout_file = get_file("layout/theme.liquid")
-
layout_file&.content || ''
-
end
-
-
def update_file(path, content)
-
file = builder_theme_files.find_or_initialize_by(path: path)
-
file.content = content
-
file.checksum = Digest::SHA256.hexdigest(content)
-
file.file_size = content.bytesize
-
-
# Ensure we have a proper tenant object
-
if tenant.is_a?(OpenStruct)
-
file.tenant = Tenant.find(tenant.tenant_id)
-
else
-
file.tenant = tenant
-
end
-
-
file.save!
-
file
-
end
-
-
def file_tree
-
@file_tree ||= build_file_tree
-
end
-
-
def can_be_published?
-
builder_theme_files.any? && !published?
-
end
-
-
def version_number
-
return 1 unless parent_version
-
-
parent_version.version_number + 1
-
end
-
-
private
-
-
def generate_checksum
-
return if checksum.present?
-
-
content = "#{theme_name}#{label}#{parent_version_id}#{Time.current.to_i}"
-
self.checksum = Digest::SHA256.hexdigest(content)
-
end
-
-
def create_initial_files
-
return unless parent_version.nil? # Only for root versions
-
-
# Copy files from the actual theme directory
-
theme_path = Rails.root.join('app', 'themes', theme_name)
-
return unless Dir.exist?(theme_path)
-
-
copy_theme_files(theme_path)
-
end
-
-
def copy_theme_files(theme_path, relative_path = '')
-
Dir.entries(theme_path).each do |entry|
-
next if entry.start_with?('.')
-
-
entry_path = File.join(theme_path, entry)
-
file_relative_path = relative_path.present? ? "#{relative_path}/#{entry}" : entry
-
-
if File.directory?(entry_path)
-
copy_theme_files(entry_path, file_relative_path)
-
else
-
content = File.read(entry_path)
-
update_file(file_relative_path, content)
-
end
-
end
-
end
-
-
def load_sections_data
-
# Load from template JSON files
-
sections = {}
-
-
builder_theme_files.where("path LIKE 'templates/%.json'").each do |file|
-
begin
-
template_data = JSON.parse(file.content)
-
sections.merge!(template_data['sections'] || {})
-
rescue JSON::ParserError
-
Rails.logger.warn "Invalid JSON in template file: #{file.path}"
-
end
-
end
-
-
sections
-
end
-
-
def load_settings_data
-
# Load from settings schema
-
settings = {}
-
-
settings_file = builder_theme_files.find_by(path: 'config/settings_schema.json')
-
return settings unless settings_file
-
-
begin
-
schema = JSON.parse(settings_file.content)
-
schema.each do |group|
-
group['settings']&.each do |setting|
-
settings[setting['id']] = setting['default']
-
end
-
end
-
rescue JSON::ParserError
-
Rails.logger.warn "Invalid JSON in settings schema: #{settings_file.path}"
-
end
-
-
settings
-
end
-
-
def build_file_tree
-
tree = {}
-
-
builder_theme_files.each do |file|
-
path_parts = file.path.split('/')
-
current = tree
-
-
path_parts[0..-2].each do |part|
-
current[part] ||= { type: 'directory', children: {} }
-
current = current[part][:children]
-
end
-
-
current[path_parts.last] = {
-
type: 'file',
-
path: file.path,
-
size: file.file_size,
-
checksum: file.checksum
-
}
-
end
-
-
tree
-
end
-
-
def initialize_default_pages
-
# Define default pages based on common templates
-
default_page_templates = {
-
'index' => 'Home Page',
-
'blog' => 'Blog Page',
-
'post' => 'Post Page',
-
'page' => 'Generic Page',
-
'search' => 'Search Results',
-
'404' => '404 Not Found',
-
'login' => 'Login Page',
-
'register' => 'Register Page',
-
'contact' => 'Contact Page',
-
'error' => 'Error Page',
-
'email' => 'Email Template',
-
'maintenance' => 'Maintenance Page'
-
}
-
-
default_page_templates.each do |template_name, page_title|
-
builder_pages.find_or_create_by!(template_name: template_name) do |page|
-
page.page_title = page_title
-
page.settings = {} # Initial empty settings
-
page.sections = {} # Initial empty sections (will be managed by BuilderPageSection)
-
page.published = true if template_name == 'index' # Publish home page by default
-
page.tenant = tenant
-
end
-
end
-
end
-
-
def default_layout
-
<<~LIQUID
-
<!DOCTYPE html>
-
<html lang="en">
-
<head>
-
<meta charset="UTF-8">
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<title>{{ page_title | default: site.title }}</title>
-
{{ content_for_header }}
-
</head>
-
<body>
-
{{ content_for_layout }}
-
{{ content_for_footer }}
-
</body>
-
</html>
-
LIQUID
-
end
-
end
-
class BuilderThemeFile < ApplicationRecord
-
# Associations
-
belongs_to :tenant
-
belongs_to :builder_theme
-
-
# Validations
-
validates :path, presence: true, uniqueness: { scope: :builder_theme_id }
-
validates :content, presence: true
-
validates :checksum, presence: true
-
validates :builder_theme, presence: true
-
-
# Callbacks
-
before_validation :generate_checksum, on: :create
-
before_validation :calculate_file_size, on: :create
-
-
# Scopes
-
scope :liquid_files, -> { where("path LIKE '%.liquid'") }
-
scope :json_files, -> { where("path LIKE '%.json'") }
-
scope :css_files, -> { where("path LIKE '%.css'") }
-
scope :js_files, -> { where("path LIKE '%.js'") }
-
scope :sections, -> { where("path LIKE 'sections/%.liquid'") }
-
scope :templates, -> { where("path LIKE 'templates/%.json'") }
-
scope :snippets, -> { where("path LIKE 'snippets/%.liquid'") }
-
scope :layouts, -> { where("path LIKE 'layout/%.liquid'") }
-
-
# Class methods
-
def self.editable_extensions
-
%w[.liquid .json .css .js .html .md .yml .yaml]
-
end
-
-
def self.editable?(path)
-
ext = File.extname(path).downcase
-
editable_extensions.include?(ext)
-
end
-
-
# Instance methods
-
def editable?
-
self.class.editable?(path)
-
end
-
-
def file_type
-
case File.extname(path).downcase
-
when '.liquid'
-
'liquid'
-
when '.json'
-
'json'
-
when '.css'
-
'css'
-
when '.js'
-
'javascript'
-
when '.html'
-
'html'
-
when '.md'
-
'markdown'
-
when '.yml', '.yaml'
-
'yaml'
-
else
-
'text'
-
end
-
end
-
-
def section_name
-
return nil unless path.start_with?('sections/')
-
File.basename(path, '.liquid')
-
end
-
-
def template_name
-
return nil unless path.start_with?('templates/')
-
File.basename(path, '.json')
-
end
-
-
def snippet_name
-
return nil unless path.start_with?('snippets/')
-
File.basename(path, '.liquid')
-
end
-
-
def layout_name
-
return nil unless path.start_with?('layout/')
-
File.basename(path, '.liquid')
-
end
-
-
def schema_data
-
return nil unless file_type == 'liquid' && content.include?('{% schema %}')
-
-
# Extract schema from liquid file
-
schema_match = content.match(/{% schema %}(.*?){% endschema %}/m)
-
return nil unless schema_match
-
-
begin
-
JSON.parse(schema_match[1].strip)
-
rescue JSON::ParserError
-
nil
-
end
-
end
-
-
def update_content!(new_content)
-
self.content = new_content
-
generate_checksum
-
calculate_file_size
-
save!
-
end
-
-
def content_changed?
-
new_checksum = Digest::SHA256.hexdigest(content)
-
checksum != new_checksum
-
end
-
-
private
-
-
def generate_checksum
-
return if content.blank?
-
self.checksum = Digest::SHA256.hexdigest(content)
-
end
-
-
def calculate_file_size
-
return if content.blank?
-
self.file_size = content.bytesize
-
end
-
end
-
class BuilderThemeSection < ApplicationRecord
-
# Associations
-
belongs_to :tenant
-
belongs_to :builder_theme
-
-
# Serialization
-
serialize :settings, coder: JSON, type: Hash
-
-
# Validations
-
validates :section_id, presence: true, uniqueness: { scope: :builder_theme_id }
-
validates :section_type, presence: true
-
validates :position, presence: true, numericality: { greater_than_or_equal_to: 0 }
-
validates :settings, presence: true
-
validates :tenant, presence: true
-
validates :builder_theme, presence: true
-
-
# Scopes
-
scope :ordered, -> { order(:position) }
-
scope :by_type, ->(type) { where(section_type: type) }
-
-
# Callbacks
-
before_validation :set_defaults, on: :create
-
-
# Class methods
-
def self.create_section(builder_theme, section_type, settings = {})
-
section_id = "#{section_type}_#{Time.current.to_i}"
-
position = builder_theme.builder_theme_sections.count
-
-
create!(
-
builder_theme: builder_theme,
-
tenant: builder_theme.tenant,
-
section_id: section_id,
-
section_type: section_type,
-
settings: settings,
-
position: position
-
)
-
end
-
-
def self.reorder_sections(builder_theme, section_ids)
-
section_ids.each_with_index do |section_id, index|
-
section = builder_theme.builder_theme_sections.find_by(section_id: section_id)
-
section&.update!(position: index)
-
end
-
end
-
-
# Instance methods
-
def update_settings!(new_settings)
-
update!(settings: settings.merge(new_settings.stringify_keys))
-
end
-
-
def get_setting(key, default = nil)
-
settings[key.to_s] || default
-
end
-
-
def set_setting(key, value)
-
self.settings = settings.merge(key.to_s => value)
-
save!
-
end
-
-
def display_name
-
case section_type
-
when 'hero'
-
'Hero'
-
when 'post-list'
-
'Blog List'
-
when 'rich-text'
-
'Rich Text'
-
when 'image'
-
'Image'
-
when 'gallery'
-
'Image Gallery'
-
when 'contact'
-
'Contact Form'
-
when 'header'
-
'Header'
-
when 'footer'
-
'Footer'
-
when 'menu'
-
'Menu'
-
when 'search-form'
-
'Search Form'
-
when 'comments'
-
'Comments'
-
when 'pagination'
-
'Pagination'
-
when 'taxonomy-list'
-
'Category/Tag List'
-
when 'seo-head'
-
'SEO Head'
-
when 'post-content'
-
'Post Content'
-
when 'related-posts'
-
'Related Posts'
-
else
-
section_type.humanize
-
end
-
end
-
-
def description
-
case section_type
-
when 'hero'
-
get_setting('heading', 'Hero section')
-
when 'post-list'
-
"Blog list (#{get_setting('items_per_page', 6)} items)"
-
when 'rich-text'
-
get_setting('content', 'Rich text content')&.truncate(50)
-
when 'image'
-
get_setting('alt_text', 'Image section')
-
when 'contact'
-
get_setting('title', 'Contact form')
-
else
-
"#{display_name} section"
-
end
-
end
-
-
private
-
-
def set_defaults
-
self.settings ||= {}
-
self.position ||= 0
-
end
-
end
-
-
class BuilderThemeSnapshot < ApplicationRecord
-
# Associations
-
belongs_to :tenant
-
belongs_to :builder_theme
-
belongs_to :user
-
-
# Validations
-
validates :theme_name, presence: true
-
validates :settings_data, presence: true
-
validates :sections_data, presence: true
-
validates :checksum, presence: true, uniqueness: true
-
validates :builder_theme, presence: true
-
validates :user, presence: true
-
-
# Scopes
-
scope :for_theme, ->(theme_name) { where(theme_name: theme_name) }
-
scope :latest, -> { order(created_at: :desc) }
-
-
# Callbacks
-
before_validation :generate_checksum, on: :create
-
-
# Class methods
-
def self.current_for_theme(theme_name)
-
for_theme(theme_name).latest.first
-
end
-
-
def self.create_from_version(builder_theme)
-
create!(
-
theme_name: builder_theme.theme_name,
-
builder_theme: builder_theme,
-
settings_data: builder_theme.settings_data.to_json,
-
sections_data: builder_theme.sections_data.to_json,
-
user: builder_theme.user
-
)
-
end
-
-
# Instance methods
-
def settings
-
@settings ||= JSON.parse(settings_data)
-
rescue JSON::ParserError
-
{}
-
end
-
-
def sections
-
@sections ||= JSON.parse(sections_data)
-
rescue JSON::ParserError
-
{}
-
end
-
-
def settings=(data)
-
@settings = data
-
self.settings_data = data.to_json
-
end
-
-
def sections=(data)
-
@sections = data
-
self.sections_data = data.to_json
-
end
-
-
def get_setting(key, default = nil)
-
settings[key.to_s] || default
-
end
-
-
def get_section(section_id)
-
sections[section_id.to_s]
-
end
-
-
def section_order
-
sections.keys
-
end
-
-
def apply_to_frontend!
-
# This method would be called to apply the snapshot to the frontend
-
# For now, we'll just log it - the actual implementation would depend
-
# on how the frontend picks up theme changes
-
-
Rails.logger.info "Applying theme snapshot #{id} for theme #{theme_name}"
-
-
# In a real implementation, this might:
-
# 1. Update a cache key
-
# 2. Trigger a webhook
-
# 3. Update a database flag that the frontend checks
-
# 4. Send a message to a queue for processing
-
-
# For now, we'll create a simple cache entry
-
Rails.cache.write("theme_snapshot_#{theme_name}", id, expires_in: 1.hour)
-
end
-
-
def rollback_to!(target_snapshot)
-
return false unless target_snapshot.theme_name == theme_name
-
-
# Create a new version based on the target snapshot
-
new_version = BuilderTheme.create_version(
-
theme_name,
-
user,
-
builder_theme,
-
"Rollback to #{target_snapshot.created_at.strftime('%Y-%m-%d %H:%M')}"
-
)
-
-
# Apply the snapshot data to the new version
-
new_version.settings_data = target_snapshot.settings
-
new_version.sections_data = target_snapshot.sections
-
new_version.save!
-
-
new_version
-
end
-
-
def diff_with(other_snapshot)
-
return {} unless other_snapshot.is_a?(BuilderThemeSnapshot)
-
-
{
-
settings: diff_hash(settings, other_snapshot.settings),
-
sections: diff_hash(sections, other_snapshot.sections)
-
}
-
end
-
-
def created_by
-
user.email
-
end
-
-
def version_info
-
{
-
id: id,
-
theme_name: theme_name,
-
created_at: created_at,
-
created_by: created_by,
-
checksum: checksum
-
}
-
end
-
-
private
-
-
def generate_checksum
-
return if checksum.present?
-
-
content = "#{settings_data}#{sections_data}#{created_at || Time.current}"
-
self.checksum = Digest::SHA256.hexdigest(content)
-
end
-
-
def diff_hash(hash1, hash2)
-
diff = {}
-
-
# Find keys that are different or new
-
(hash1.keys + hash2.keys).uniq.each do |key|
-
val1 = hash1[key]
-
val2 = hash2[key]
-
-
if val1 != val2
-
diff[key] = {
-
from: val1,
-
to: val2,
-
type: val1.nil? ? 'added' : (val2.nil? ? 'removed' : 'changed')
-
}
-
end
-
end
-
-
diff
-
end
-
end
-
class Channel < ApplicationRecord
-
# Multi-tenancy
-
# acts_as_tenant(:tenant, optional: true) # Temporarily disabled for testing
-
-
# Associations
-
has_and_belongs_to_many :posts
-
has_and_belongs_to_many :pages
-
has_and_belongs_to_many :media, class_name: 'Medium'
-
has_many :channel_overrides, dependent: :destroy
-
-
# Validations
-
validates :name, presence: true
-
validates :slug, presence: true, uniqueness: true
-
validates :locale, presence: true
-
-
# Scopes
-
scope :active, -> { where(enabled: true) }
-
scope :by_domain, ->(domain) { where(domain: domain) }
-
scope :by_locale, ->(locale) { where(locale: locale) }
-
-
# Callbacks
-
before_validation :set_default_locale
-
before_validation :generate_slug_from_name, if: -> { slug.blank? }
-
-
# Methods
-
def self.find_by_domain(domain)
-
find_by(domain: domain)
-
end
-
-
def self.find_by_slug(slug)
-
find_by(slug: slug)
-
end
-
-
def override_for(resource_type, resource_id, path)
-
channel_overrides.find_by(
-
resource_type: resource_type,
-
resource_id: resource_id,
-
path: path,
-
enabled: true
-
)
-
end
-
-
def overrides_for(resource_type, resource_id)
-
channel_overrides.where(
-
resource_type: resource_type,
-
resource_id: resource_id,
-
kind: 'override',
-
enabled: true
-
)
-
end
-
-
def exclusions_for(resource_type, resource_id)
-
channel_overrides.where(
-
resource_type: resource_type,
-
resource_id: resource_id,
-
kind: 'exclude',
-
enabled: true
-
)
-
end
-
-
def excluded?(resource_type, resource_id)
-
exclusions_for(resource_type, resource_id).exists?
-
end
-
-
def apply_overrides_to_data(data, resource_type, resource_id, include_provenance = false)
-
overrides = overrides_for(resource_type, resource_id)
-
return data, {} if overrides.empty?
-
-
result = data.deep_dup
-
provenance = {}
-
-
overrides.each do |override|
-
path_parts = override.path.split('.')
-
current = result
-
-
# Navigate to the parent of the target key
-
path_parts[0..-2].each do |part|
-
current = current[part] ||= {}
-
end
-
-
# Set the final value
-
current[path_parts.last] = override.data
-
-
# Track provenance if requested
-
if include_provenance
-
provenance[override.path] = 'channel_override'
-
end
-
end
-
-
if include_provenance
-
return result, provenance
-
else
-
return result
-
end
-
end
-
-
def to_liquid
-
{
-
'id' => id,
-
'name' => name,
-
'slug' => slug,
-
'domain' => domain,
-
'locale' => locale,
-
'metadata' => metadata,
-
'settings' => settings
-
}
-
end
-
-
private
-
-
def set_default_locale
-
self.locale ||= 'en'
-
end
-
-
def generate_slug_from_name
-
self.slug = name.parameterize if name.present?
-
end
-
end
-
class ChannelOverride < ApplicationRecord
-
belongs_to :channel
-
-
# Validations
-
validates :resource_type, presence: true
-
validates :kind, presence: true, inclusion: { in: %w[override exclude] }
-
validates :path, presence: true
-
validates :resource_id, presence: true, if: -> { resource_type.present? }
-
-
# Scopes
-
scope :overrides, -> { where(kind: 'override') }
-
scope :exclusions, -> { where(kind: 'exclude') }
-
scope :enabled, -> { where(enabled: true) }
-
scope :for_resource, ->(resource_type, resource_id) { where(resource_type: resource_type, resource_id: resource_id) }
-
scope :for_path, ->(path) { where(path: path) }
-
-
# Methods
-
def resource
-
return nil unless resource_type.present? && resource_id.present?
-
-
case resource_type
-
when 'Post'
-
Post.find_by(id: resource_id)
-
when 'Page'
-
Page.find_by(id: resource_id)
-
when 'Medium'
-
Medium.find_by(id: resource_id)
-
when 'Setting'
-
SiteSetting.find_by(id: resource_id)
-
else
-
resource_type.constantize.find_by(id: resource_id) rescue nil
-
end
-
end
-
-
def resource_name
-
resource&.title || resource&.name || "#{resource_type} ##{resource_id}"
-
end
-
-
def is_override?
-
kind == 'override'
-
end
-
-
def is_exclusion?
-
kind == 'exclude'
-
end
-
-
def apply_to_data(data)
-
return data if !enabled? || !is_override?
-
-
path_parts = path.split('.')
-
current = data
-
-
# Navigate to the parent of the target key
-
path_parts[0..-2].each do |part|
-
current = current[part] ||= {}
-
end
-
-
# Set the final value
-
current[path_parts.last] = self.data
-
-
data
-
end
-
-
def should_exclude_resource?
-
enabled? && is_exclusion?
-
end
-
end
-
class Comment < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant, optional: true)
-
-
# Trash functionality
-
include Trashable
-
-
belongs_to :user, optional: true
-
belongs_to :commentable, polymorphic: true
-
belongs_to :parent, class_name: 'Comment', optional: true
-
belongs_to :comment_parent, class_name: 'Comment', optional: true
-
-
# Hierarchical comments (threaded)
-
has_many :replies, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy
-
has_many :comment_replies, class_name: 'Comment', foreign_key: 'comment_parent_id', dependent: :destroy
-
-
# Status enum
-
enum status: {
-
pending: 0,
-
approved: 1,
-
spam: 2,
-
trash: 3
-
}
-
-
# Comment type enum
-
enum comment_type: {
-
comment: 'comment',
-
pingback: 'pingback',
-
trackback: 'trackback'
-
}
-
-
# Validations
-
validates :content, presence: true
-
validates :author_name, presence: true, unless: :user_id?
-
validates :author_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }, unless: :user_id?
-
validates :status, presence: true
-
validates :comment_type, presence: true
-
validates :comment_approved, presence: true, inclusion: { in: %w[0 1] }
-
validates :author_ip, presence: true
-
validates :author_agent, presence: true
-
-
# Scopes
-
scope :approved, -> { where(status: :approved) }
-
scope :recent, -> { order(created_at: :desc) }
-
scope :root_comments, -> { where(parent_id: nil) }
-
scope :comments_only, -> { where(comment_type: :comment) }
-
scope :pingbacks, -> { where(comment_type: :pingback) }
-
scope :trackbacks, -> { where(comment_type: :trackback) }
-
-
# Callbacks
-
after_initialize :set_defaults, if: :new_record?
-
after_create :trigger_comment_created_hook
-
after_update :trigger_comment_status_changed_hook, if: :saved_change_to_status?
-
-
# Methods
-
def author
-
user&.email&.split('@')&.first || author_name
-
end
-
-
def approved?
-
comment_approved == '1'
-
end
-
-
def pending?
-
comment_approved == '0'
-
end
-
-
def approve!
-
update!(comment_approved: '1', status: :approved)
-
end
-
-
def unapprove!
-
update!(comment_approved: '0', status: :pending)
-
end
-
-
def browser_info
-
return 'Unknown' unless author_agent.present?
-
-
# Simple browser detection
-
case author_agent.downcase
-
when /chrome/
-
'Chrome'
-
when /firefox/
-
'Firefox'
-
when /safari/
-
'Safari'
-
when /edge/
-
'Edge'
-
when /opera/
-
'Opera'
-
else
-
'Other'
-
end
-
end
-
-
def is_reply?
-
comment_parent_id.present?
-
end
-
-
def is_threaded_reply?
-
parent_id.present?
-
end
-
-
# Convert Comment to Liquid-compatible hash
-
def to_liquid
-
{
-
'id' => id,
-
'content' => content,
-
'author' => author,
-
'author_name' => author_name,
-
'author_email' => author_email,
-
'author_url' => author_url,
-
'created_at' => created_at,
-
'updated_at' => updated_at,
-
'status' => status,
-
'comment_type' => comment_type,
-
'approved' => approved?,
-
'pending' => pending?,
-
'is_reply' => is_reply?,
-
'is_threaded_reply' => is_threaded_reply?,
-
'parent_id' => parent_id,
-
'comment_parent_id' => comment_parent_id,
-
'browser_info' => browser_info,
-
'user' => user,
-
'replies' => replies.to_a, # Convert AssociationRelation to array
-
'comment_replies' => comment_replies.to_a # Convert AssociationRelation to array
-
}
-
end
-
-
# Make methods public for Liquid access
-
public :to_liquid
-
-
private
-
-
def set_defaults
-
self.status ||= :pending
-
self.comment_type ||= :comment
-
self.comment_approved ||= '0'
-
self.author_ip ||= '127.0.0.1'
-
self.author_agent ||= 'Unknown'
-
end
-
-
def trigger_comment_created_hook
-
Railspress::PluginSystem.do_action('comment_created', self)
-
end
-
-
def trigger_comment_status_changed_hook
-
if approved?
-
Railspress::PluginSystem.do_action('comment_approved', self)
-
elsif spam?
-
Railspress::PluginSystem.do_action('comment_marked_spam', self)
-
end
-
end
-
end
-
module Railspress
-
module ChannelDetection
-
extend ActiveSupport::Concern
-
-
# Device detection patterns based on user agent strings
-
DEVICE_PATTERNS = {
-
mobile: [
-
/iPhone/i, /Android/i, /Mobile/i, /BlackBerry/i, /Windows Phone/i,
-
/Opera Mini/i, /IEMobile/i, /webOS/i, /Palm/i, /Nokia/i
-
],
-
tablet: [
-
/iPad/i, /Android.*Tablet/i, /Kindle/i, /Silk/i, /PlayBook/i,
-
/BB10/i, /Tablet/i, /Nexus 7/i, /Nexus 10/i
-
],
-
smart_tv: [
-
/SmartTV/i, /TV/i, /Roku/i, /AppleTV/i, /AndroidTV/i, /WebOS/i,
-
/Tizen/i, /NetCast/i, /BRAVIA/i, /Samsung/i, /LG/i
-
],
-
desktop: [
-
/Windows/i, /Macintosh/i, /Linux/i, /X11/i, /Win64/i, /WOW64/i
-
]
-
}.freeze
-
-
# Email client detection patterns
-
EMAIL_CLIENT_PATTERNS = [
-
/Outlook/i, /Gmail/i, /Apple Mail/i, /Thunderbird/i, /Mail/i,
-
/Yahoo Mail/i, /Hotmail/i, /AOL/i, /Zimbra/i
-
].freeze
-
-
class_methods do
-
# Detect device type from user agent string
-
def detect_device_type(user_agent)
-
return :email if email_client?(user_agent)
-
-
DEVICE_PATTERNS.each do |device_type, patterns|
-
patterns.each do |pattern|
-
return device_type if user_agent.match?(pattern)
-
end
-
end
-
-
:desktop # Default fallback
-
end
-
-
# Check if user agent is an email client
-
def email_client?(user_agent)
-
EMAIL_CLIENT_PATTERNS.any? { |pattern| user_agent.match?(pattern) }
-
end
-
-
# Get appropriate channel for device type
-
def channel_for_device(device_type)
-
case device_type
-
when :mobile, :tablet
-
Channel.find_by(slug: 'mobile')
-
when :smart_tv
-
Channel.find_by(slug: 'smarttv')
-
when :email
-
Channel.find_by(slug: 'newsletter')
-
else
-
Channel.find_by(slug: 'web')
-
end
-
end
-
-
# Auto-detect and return appropriate channel
-
def auto_detect_channel(user_agent)
-
device_type = detect_device_type(user_agent)
-
channel_for_device(device_type)
-
end
-
-
# Get channel-specific settings for rendering
-
def channel_settings_for_device(device_type)
-
channel = channel_for_device(device_type)
-
return {} unless channel
-
-
channel.settings.merge(
-
'device_type' => device_type,
-
'channel_slug' => channel.slug,
-
'channel_name' => channel.name
-
)
-
end
-
end
-
-
# Instance methods for applying channel settings
-
def apply_channel_settings(data, user_agent = nil)
-
return data unless user_agent
-
-
device_type = self.class.detect_device_type(user_agent)
-
channel = self.class.channel_for_device(device_type)
-
-
return data unless channel
-
-
# Apply channel-specific overrides
-
if respond_to?(:apply_overrides_to_data)
-
overridden_data, provenance = apply_overrides_to_data(
-
data,
-
self.class.name,
-
id,
-
true
-
)
-
-
# Add channel context
-
overridden_data.merge!(
-
'channel_context' => channel.slug,
-
'device_type' => device_type,
-
'provenance' => provenance
-
)
-
else
-
data.merge!(
-
'channel_context' => channel.slug,
-
'device_type' => device_type
-
)
-
end
-
-
overridden_data || data
-
end
-
-
# Get optimized content for specific device
-
def content_for_device(device_type)
-
channel = self.class.channel_for_device(device_type)
-
return content unless channel
-
-
# Apply device-specific optimizations
-
optimized_content = content.dup
-
-
case device_type
-
when :mobile, :tablet
-
# Mobile optimizations
-
optimized_content = optimize_for_mobile(optimized_content)
-
when :smart_tv
-
# TV optimizations
-
optimized_content = optimize_for_tv(optimized_content)
-
when :email
-
# Email optimizations
-
optimized_content = optimize_for_email(optimized_content)
-
end
-
-
optimized_content
-
end
-
-
private
-
-
def optimize_for_mobile(content)
-
# Remove heavy elements, optimize images, etc.
-
content.gsub(/<iframe[^>]*>/i, '') # Remove iframes
-
.gsub(/width="\d+"/i, '') # Remove width attributes
-
.gsub(/height="\d+"/i, '') # Remove height attributes
-
end
-
-
def optimize_for_tv(content)
-
# Optimize for large screens and remote navigation
-
content.gsub(/<img([^>]*)>/i, '<img\1 style="max-width: 100%; height: auto;">')
-
.gsub(/font-size:\s*\d+px/i, 'font-size: 24px') # Larger text
-
end
-
-
def optimize_for_email(content)
-
# Email client compatibility
-
content.gsub(/style="[^"]*"/i, '') # Remove inline styles
-
.gsub(/<div([^>]*)>/i, '<table><tr><td\1>') # Convert divs to tables
-
.gsub(/<\/div>/i, '</td></tr></table>')
-
end
-
end
-
end
-
module HasTaxonomies
-
extend ActiveSupport::Concern
-
-
included do
-
has_many :term_relationships, as: :object, dependent: :destroy
-
has_many :terms, through: :term_relationships
-
end
-
-
class_methods do
-
# Register a taxonomy for this model
-
def has_taxonomy(taxonomy_slug, options = {})
-
taxonomy_name = options[:taxonomy_name] || taxonomy_slug.to_s.pluralize
-
-
# Define association
-
has_many :"#{taxonomy_slug}_relationships",
-
-> { joins(:term).where(terms: { taxonomy_id: Taxonomy.find_by(slug: taxonomy_slug)&.id }) },
-
class_name: 'TermRelationship',
-
as: :object,
-
dependent: :destroy
-
-
has_many taxonomy_slug.to_sym,
-
through: :"#{taxonomy_slug}_relationships",
-
source: :term
-
-
# Define helper methods
-
define_method "#{taxonomy_slug}_list" do
-
send(taxonomy_slug).pluck(:name).join(', ')
-
end
-
-
define_method "#{taxonomy_slug}_list=" do |names|
-
taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
-
return unless taxonomy
-
-
term_names = names.split(',').map(&:strip).reject(&:blank?)
-
new_terms = term_names.map do |name|
-
taxonomy.terms.find_or_create_by!(name: name)
-
end
-
-
send("#{taxonomy_slug}=", new_terms)
-
end
-
end
-
end
-
-
# Instance methods
-
-
# Get all terms for a specific taxonomy
-
def terms_for_taxonomy(taxonomy_slug)
-
taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
-
return Term.none unless taxonomy
-
-
terms.where(taxonomy_id: taxonomy.id)
-
end
-
-
# Set terms for a taxonomy
-
def set_terms_for_taxonomy(taxonomy_slug, term_ids)
-
taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
-
return unless taxonomy
-
-
# Remove existing terms for this taxonomy
-
term_relationships.joins(:term)
-
.where(terms: { taxonomy_id: taxonomy.id })
-
.destroy_all
-
-
# Add new terms
-
Array(term_ids).each do |term_id|
-
term = taxonomy.terms.find_by(id: term_id)
-
terms << term if term && !terms.include?(term)
-
end
-
end
-
-
# Add a single term
-
def add_term(term_or_name, taxonomy_slug)
-
taxonomy = Taxonomy.find_by(slug: taxonomy_slug)
-
return unless taxonomy
-
-
term = if term_or_name.is_a?(Term)
-
term_or_name
-
else
-
taxonomy.terms.find_or_create_by!(name: term_or_name)
-
end
-
-
terms << term unless terms.include?(term)
-
end
-
-
# Remove a term
-
def remove_term(term)
-
terms.delete(term)
-
end
-
-
# Check if has term
-
def has_term?(term_or_slug)
-
if term_or_slug.is_a?(Term)
-
terms.include?(term_or_slug)
-
else
-
terms.exists?(slug: term_or_slug)
-
end
-
end
-
-
# Get term names for taxonomy
-
def term_names_for(taxonomy_slug)
-
terms_for_taxonomy(taxonomy_slug).pluck(:name)
-
end
-
end
-
-
-
-
-
-
-
-
-
1
module Metable
-
1
extend ActiveSupport::Concern
-
-
1
included do
-
# This will be added by the has_many :meta_fields association
-
end
-
-
# Convenience methods for meta fields
-
1
def get_meta(key)
-
MetaField.get(self, key)
-
end
-
-
1
def set_meta(key, value, immutable: false)
-
MetaField.set(self, key, value, immutable: immutable)
-
end
-
-
1
def delete_meta(key)
-
MetaField.delete(self, key)
-
end
-
-
1
def bulk_get_meta(keys)
-
MetaField.bulk_get(self, keys)
-
end
-
-
1
def bulk_set_meta(hash, immutable: false)
-
MetaField.bulk_set(self, hash, immutable: immutable)
-
end
-
-
1
def all_meta
-
MetaField.all_for(self)
-
end
-
-
1
def has_meta?(key)
-
get_meta(key).present?
-
end
-
-
1
def meta_keys
-
meta_fields.pluck(:key)
-
end
-
-
1
def immutable_meta_keys
-
meta_fields.immutable.pluck(:key)
-
end
-
-
1
def mutable_meta_keys
-
meta_fields.mutable.pluck(:key)
-
end
-
-
# Plugin helpers for common use cases
-
1
def get_meta_as_string(key, default = "")
-
value = get_meta(key)
-
then: 0
else: 0
value.present? ? value.to_s : default
-
end
-
-
1
def get_meta_as_integer(key, default = 0)
-
value = get_meta(key)
-
then: 0
else: 0
value.present? ? value.to_i : default
-
end
-
-
1
def get_meta_as_float(key, default = 0.0)
-
value = get_meta(key)
-
then: 0
else: 0
value.present? ? value.to_f : default
-
end
-
-
1
def get_meta_as_boolean(key, default = false)
-
value = get_meta(key)
-
then: 0
else: 0
return default if value.blank?
-
-
case value.to_s.downcase
-
when: 0
when 'true', '1', 'yes', 'on'
-
true
-
when: 0
when 'false', '0', 'no', 'off'
-
false
-
else: 0
else
-
default
-
end
-
end
-
-
1
def get_meta_as_json(key, default = {})
-
value = get_meta(key)
-
then: 0
else: 0
return default if value.blank?
-
-
begin
-
JSON.parse(value)
-
rescue JSON::ParserError
-
default
-
end
-
end
-
-
1
def set_meta_json(key, value, immutable: false)
-
set_meta(key, value.to_json, immutable: immutable)
-
end
-
-
# Clear all meta fields (useful for cleanup)
-
1
def clear_all_meta!
-
meta_fields.mutable.destroy_all
-
end
-
-
# Plugin namespace helpers
-
1
def get_plugin_meta(plugin_name, key)
-
get_meta("#{plugin_name}:#{key}")
-
end
-
-
1
def set_plugin_meta(plugin_name, key, value, immutable: false)
-
set_meta("#{plugin_name}:#{key}", value, immutable: immutable)
-
end
-
-
1
def delete_plugin_meta(plugin_name, key)
-
delete_meta("#{plugin_name}:#{key}")
-
end
-
-
1
def bulk_get_plugin_meta(plugin_name, keys)
-
prefixed_keys = keys.map { |key| "#{plugin_name}:#{key}" }
-
values = bulk_get_meta(prefixed_keys)
-
keys.zip(values).to_h
-
end
-
-
1
def bulk_set_plugin_meta(plugin_name, hash, immutable: false)
-
prefixed_hash = hash.transform_keys { |key| "#{plugin_name}:#{key}" }
-
bulk_set_meta(prefixed_hash, immutable: immutable)
-
end
-
-
1
def get_all_plugin_meta(plugin_name)
-
all_meta.select { |key, _| key.start_with?("#{plugin_name}:") }
-
.transform_keys { |key| key.sub("#{plugin_name}:", "") }
-
then: 0
else: 0
.transform_values { |meta_data| meta_data.is_a?(Hash) ? meta_data[:value] : meta_data }
-
end
-
-
1
def delete_all_plugin_meta(plugin_name)
-
meta_fields.where("key LIKE ?", "#{plugin_name}:%").destroy_all
-
end
-
end
-
module Railspress
-
module ChannelDetection
-
extend ActiveSupport::Concern
-
-
# Device detection patterns based on user agent strings
-
DEVICE_PATTERNS = {
-
mobile: [
-
/iPhone/i, /Android/i, /Mobile/i, /BlackBerry/i, /Windows Phone/i,
-
/Opera Mini/i, /IEMobile/i, /webOS/i, /Palm/i, /Nokia/i
-
],
-
tablet: [
-
/iPad/i, /Android.*Tablet/i, /Kindle/i, /Silk/i, /PlayBook/i,
-
/BB10/i, /Tablet/i, /Nexus 7/i, /Nexus 10/i
-
],
-
smart_tv: [
-
/SmartTV/i, /TV/i, /Roku/i, /AppleTV/i, /AndroidTV/i, /WebOS/i,
-
/Tizen/i, /NetCast/i, /BRAVIA/i, /Samsung/i, /LG/i
-
],
-
desktop: [
-
/Windows/i, /Macintosh/i, /Linux/i, /X11/i, /Win64/i, /WOW64/i
-
]
-
}.freeze
-
-
# Email client detection patterns
-
EMAIL_CLIENT_PATTERNS = [
-
/Outlook/i, /Gmail/i, /Apple Mail/i, /Thunderbird/i, /Mail/i,
-
/Yahoo Mail/i, /Hotmail/i, /AOL/i, /Zimbra/i
-
].freeze
-
-
class_methods do
-
# Detect device type from user agent string
-
def detect_device_type(user_agent)
-
return :email if email_client?(user_agent)
-
-
DEVICE_PATTERNS.each do |device_type, patterns|
-
patterns.each do |pattern|
-
return device_type if user_agent.match?(pattern)
-
end
-
end
-
-
:desktop # Default fallback
-
end
-
-
# Check if user agent is an email client
-
def email_client?(user_agent)
-
EMAIL_CLIENT_PATTERNS.any? { |pattern| user_agent.match?(pattern) }
-
end
-
-
# Get appropriate channel for device type
-
def channel_for_device(device_type)
-
case device_type
-
when :mobile, :tablet
-
Channel.find_by(slug: 'mobile')
-
when :smart_tv
-
Channel.find_by(slug: 'smarttv')
-
when :email
-
Channel.find_by(slug: 'newsletter')
-
else
-
Channel.find_by(slug: 'web')
-
end
-
end
-
-
# Auto-detect and return appropriate channel
-
def auto_detect_channel(user_agent)
-
device_type = detect_device_type(user_agent)
-
channel_for_device(device_type)
-
end
-
-
# Get channel-specific settings for rendering
-
def channel_settings_for_device(device_type)
-
channel = channel_for_device(device_type)
-
return {} unless channel
-
-
channel.settings.merge(
-
'device_type' => device_type,
-
'channel_slug' => channel.slug,
-
'channel_name' => channel.name
-
)
-
end
-
end
-
-
# Instance methods for applying channel settings
-
def apply_channel_settings(data, user_agent = nil)
-
return data unless user_agent
-
-
device_type = self.class.detect_device_type(user_agent)
-
channel = self.class.channel_for_device(device_type)
-
-
return data unless channel
-
-
# Apply channel-specific overrides
-
if respond_to?(:apply_overrides_to_data)
-
overridden_data, provenance = apply_overrides_to_data(
-
data,
-
self.class.name,
-
id,
-
true
-
)
-
-
# Add channel context
-
overridden_data.merge!(
-
'channel_context' => channel.slug,
-
'device_type' => device_type,
-
'provenance' => provenance
-
)
-
else
-
data.merge!(
-
'channel_context' => channel.slug,
-
'device_type' => device_type
-
)
-
end
-
-
overridden_data || data
-
end
-
-
# Get optimized content for specific device
-
def content_for_device(device_type)
-
channel = self.class.channel_for_device(device_type)
-
return content unless channel
-
-
# Apply device-specific optimizations
-
optimized_content = content.dup
-
-
case device_type
-
when :mobile, :tablet
-
# Mobile optimizations
-
optimized_content = optimize_for_mobile(optimized_content)
-
when :smart_tv
-
# TV optimizations
-
optimized_content = optimize_for_tv(optimized_content)
-
when :email
-
# Email optimizations
-
optimized_content = optimize_for_email(optimized_content)
-
end
-
-
optimized_content
-
end
-
-
private
-
-
def optimize_for_mobile(content)
-
# Remove heavy elements, optimize images, etc.
-
content.gsub(/<iframe[^>]*>/i, '') # Remove iframes
-
.gsub(/width="\d+"/i, '') # Remove width attributes
-
.gsub(/height="\d+"/i, '') # Remove height attributes
-
end
-
-
def optimize_for_tv(content)
-
# Optimize for large screens and remote navigation
-
content.gsub(/<img([^>]*)>/i, '<img\1 style="max-width: 100%; height: auto;">')
-
.gsub(/font-size:\s*\d+px/i, 'font-size: 24px') # Larger text
-
end
-
-
def optimize_for_email(content)
-
# Email client compatibility
-
content.gsub(/style="[^"]*"/i, '') # Remove inline styles
-
.gsub(/<div([^>]*)>/i, '<table><tr><td\1>') # Convert divs to tables
-
.gsub(/<\/div>/i, '</td></tr></table>')
-
end
-
end
-
end
-
module Sanitizable
-
extend ActiveSupport::Concern
-
-
included do
-
# Define which attributes should be sanitized
-
class_attribute :sanitizable_attributes
-
self.sanitizable_attributes = []
-
-
before_validation :sanitize_content_attributes
-
end
-
-
class_methods do
-
# Define attributes that should be sanitized
-
# Example: sanitize_content :body, :excerpt
-
def sanitize_content(*attributes)
-
self.sanitizable_attributes += attributes.map(&:to_s)
-
end
-
end
-
-
private
-
-
def sanitize_content_attributes
-
self.class.sanitizable_attributes.each do |attribute|
-
next unless respond_to?(attribute)
-
next if send(attribute).blank?
-
-
# Get the current value
-
value = send(attribute)
-
-
# Skip if it's ActionText (already handled)
-
next if value.is_a?(ActionText::RichText)
-
-
# Sanitize the content
-
sanitized = Railspress::HtmlSanitizer.sanitize_content(value.to_s)
-
-
# Set the sanitized value
-
send("#{attribute}=", sanitized)
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
module SeoOptimizable
-
extend ActiveSupport::Concern
-
-
included do
-
# Callbacks
-
before_validation :set_default_seo_fields
-
-
# Validations
-
validates :meta_description, length: { maximum: 160 }, allow_blank: true
-
validates :og_description, length: { maximum: 200 }, allow_blank: true
-
validates :twitter_description, length: { maximum: 200 }, allow_blank: true
-
end
-
-
# SEO meta title (falls back to title)
-
def seo_title
-
meta_title.presence || title
-
end
-
-
# SEO meta description (falls back to excerpt)
-
def seo_description
-
meta_description.presence || excerpt.presence || title
-
end
-
-
# Open Graph title (falls back to meta_title or title)
-
def seo_og_title
-
og_title.presence || meta_title.presence || title
-
end
-
-
# Open Graph description (falls back to meta_description or excerpt)
-
def seo_og_description
-
og_description.presence || meta_description.presence || excerpt.presence || title
-
end
-
-
# Open Graph image URL
-
def seo_og_image
-
og_image_url.presence || (featured_image_url if respond_to?(:featured_image_url))
-
end
-
-
# Twitter card title
-
def seo_twitter_title
-
twitter_title.presence || seo_og_title
-
end
-
-
# Twitter card description
-
def seo_twitter_description
-
twitter_description.presence || seo_og_description
-
end
-
-
# Twitter card image
-
def seo_twitter_image
-
twitter_image_url.presence || seo_og_image
-
end
-
-
# Canonical URL (falls back to generated URL)
-
def seo_canonical_url
-
canonical_url.presence || seo_default_url
-
end
-
-
# Robots meta tag
-
def seo_robots
-
robots_meta.presence || 'index, follow'
-
end
-
-
# Generate structured data (Schema.org)
-
def structured_data
-
{
-
"@context": "https://schema.org",
-
"@type": schema_type.presence || default_schema_type,
-
"headline": seo_title,
-
"description": seo_description,
-
"image": seo_og_image,
-
"datePublished": published_at&.iso8601,
-
"dateModified": updated_at&.iso8601,
-
"author": author_structured_data,
-
"publisher": publisher_structured_data,
-
"url": seo_canonical_url,
-
"keywords": meta_keywords
-
}.compact
-
end
-
-
private
-
-
def set_default_seo_fields
-
# Auto-generate meta fields if not set
-
self.meta_title ||= title if title.present?
-
self.meta_description ||= generate_meta_description if respond_to?(:content)
-
self.canonical_url ||= seo_default_url
-
end
-
-
def generate_meta_description
-
return excerpt if respond_to?(:excerpt) && excerpt.present?
-
return nil unless respond_to?(:content) && content.present?
-
-
# Extract plain text and truncate
-
plain_text = content.to_plain_text
-
plain_text.truncate(160, separator: ' ', omission: '...')
-
end
-
-
def seo_default_url
-
# Override in model if needed
-
"#"
-
end
-
-
def default_schema_type
-
self.class.name # 'Post' or 'Page'
-
end
-
-
def author_structured_data
-
return nil unless respond_to?(:user) && user.present?
-
-
{
-
"@type": "Person",
-
"name": user.email,
-
"url": "#"
-
}
-
end
-
-
def publisher_structured_data
-
{
-
"@type": "Organization",
-
"name": SiteSetting.get('site_title', 'RailsPress'),
-
"url": Rails.application.routes.url_helpers.root_url
-
}
-
rescue
-
nil
-
end
-
end
-
-
-
-
-
-
-
-
-
module Trashable
-
extend ActiveSupport::Concern
-
-
included do
-
# Scopes
-
scope :kept, -> { where(deleted_at: nil) }
-
scope :trashed, -> { where.not(deleted_at: nil) }
-
scope :trashed_before, ->(date) { where('deleted_at < ?', date) }
-
-
# Associations
-
belongs_to :trashed_by, class_name: 'User', optional: true
-
end
-
-
# Instance methods
-
def trashed?
-
deleted_at.present?
-
end
-
-
def kept?
-
deleted_at.nil?
-
end
-
-
def trash!(user = nil)
-
update!(
-
deleted_at: Time.current,
-
trashed_by: user
-
)
-
-
# Trigger plugin hook
-
Railspress::PluginSystem.do_action("#{self.class.name.downcase}_trashed", self)
-
end
-
-
def untrash!
-
update!(
-
deleted_at: nil,
-
trashed_by: nil
-
)
-
-
# Trigger plugin hook
-
Railspress::PluginSystem.do_action("#{self.class.name.downcase}_untrashed", self)
-
end
-
-
def destroy_permanently!
-
# Trigger plugin hook before permanent deletion
-
Railspress::PluginSystem.do_action("#{self.class.name.downcase}_permanently_deleted", self)
-
-
super
-
end
-
-
# Class methods
-
class_methods do
-
def cleanup_trash!
-
settings = TrashSetting.current
-
return unless settings.auto_cleanup_enabled?
-
-
threshold = settings.cleanup_threshold
-
trashed_before(threshold).find_each(&:destroy_permanently!)
-
end
-
-
def trash_count
-
trashed.count
-
end
-
-
def kept_count
-
kept.count
-
end
-
end
-
end
-
-
class ConsentConfiguration < ApplicationRecord
-
acts_as_tenant(:tenant)
-
-
# Serialization
-
serialize :consent_categories, coder: JSON, type: Hash
-
serialize :pixel_consent_mapping, coder: JSON, type: Hash
-
serialize :banner_settings, coder: JSON, type: Hash
-
serialize :geolocation_settings, coder: JSON, type: Hash
-
-
# Validations
-
validates :name, presence: true
-
validates :banner_type, inclusion: { in: %w[bottom_banner modal overlay] }
-
validates :consent_mode, inclusion: { in: %w[opt_in opt_out implied] }
-
-
# Default consent categories
-
DEFAULT_CONSENT_CATEGORIES = {
-
'necessary' => {
-
'name' => 'Necessary Cookies',
-
'description' => 'These cookies are essential for the website to function and cannot be switched off.',
-
'required' => true,
-
'default_enabled' => true,
-
'pixels' => []
-
},
-
'analytics' => {
-
'name' => 'Analytics Cookies',
-
'description' => 'These cookies help us understand how visitors interact with our website.',
-
'required' => false,
-
'default_enabled' => false,
-
'pixels' => ['google_analytics', 'google_tag_manager', 'clarity', 'hotjar']
-
},
-
'marketing' => {
-
'name' => 'Marketing Cookies',
-
'description' => 'These cookies are used to track visitors across websites for advertising purposes.',
-
'required' => false,
-
'default_enabled' => false,
-
'pixels' => ['facebook_pixel', 'tiktok_pixel', 'linkedin_insight', 'twitter_pixel', 'pinterest_tag', 'snapchat_pixel', 'reddit_pixel']
-
},
-
'functional' => {
-
'name' => 'Functional Cookies',
-
'description' => 'These cookies enable enhanced functionality and personalization.',
-
'required' => false,
-
'default_enabled' => false,
-
'pixels' => ['mixpanel', 'segment', 'heap']
-
}
-
}.freeze
-
-
# Default banner settings
-
DEFAULT_BANNER_SETTINGS = {
-
'enabled' => true,
-
'position' => 'bottom',
-
'theme' => 'dark',
-
'show_manage_preferences' => true,
-
'show_reject_all' => true,
-
'show_accept_all' => true,
-
'show_necessary_only' => true,
-
'auto_hide_after_accept' => true,
-
'auto_hide_delay' => 3000,
-
'animation_duration' => 300,
-
'custom_css' => '',
-
'text' => {
-
'title' => 'We use cookies to enhance your experience',
-
'description' => 'We use cookies and similar technologies to provide, protect, and improve our services and to show you relevant content and ads.',
-
'accept_all' => 'Accept All',
-
'reject_all' => 'Reject All',
-
'necessary_only' => 'Necessary Only',
-
'manage_preferences' => 'Manage Preferences',
-
'save_preferences' => 'Save Preferences',
-
'close' => 'Close'
-
},
-
'colors' => {
-
'primary' => '#3b82f6',
-
'secondary' => '#6b7280',
-
'background' => '#1f2937',
-
'text' => '#ffffff',
-
'button_accept' => '#10b981',
-
'button_reject' => '#ef4444',
-
'button_neutral' => '#6b7280'
-
},
-
'fonts' => {
-
'family' => 'system-ui, -apple-system, sans-serif',
-
'size_title' => '18px',
-
'size_description' => '14px',
-
'size_button' => '14px'
-
}
-
}.freeze
-
-
# Default geolocation settings
-
DEFAULT_GEOLOCATION_SETTINGS = {
-
'enabled' => true,
-
'eu_countries' => %w[AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE],
-
'us_states' => %w[CA CO CT DE HI IL IA ME MD MA MI MN NH NJ NM NY OR PA RI TX UT VT VA WA],
-
'uk_countries' => %w[GB],
-
'canada_provinces' => %w[AB BC MB NB NL NS NT NU ON PE QC SK YT],
-
'auto_detect' => true,
-
'fallback_consent_mode' => 'opt_in',
-
'region_specific_settings' => {
-
'eu' => {
-
'consent_mode' => 'opt_in',
-
'show_detailed_preferences' => true,
-
'require_explicit_consent' => true
-
},
-
'us' => {
-
'consent_mode' => 'opt_out',
-
'show_detailed_preferences' => false,
-
'require_explicit_consent' => false
-
},
-
'uk' => {
-
'consent_mode' => 'opt_in',
-
'show_detailed_preferences' => true,
-
'require_explicit_consent' => true
-
},
-
'ca' => {
-
'consent_mode' => 'opt_in',
-
'show_detailed_preferences' => true,
-
'require_explicit_consent' => true
-
}
-
}
-
}.freeze
-
-
# Callbacks
-
after_initialize :set_defaults, if: :new_record?
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :by_banner_type, ->(type) { where(banner_type: type) }
-
-
# Instance methods
-
-
def consent_categories_with_defaults
-
DEFAULT_CONSENT_CATEGORIES.merge(consent_categories || {})
-
end
-
-
def banner_settings_with_defaults
-
DEFAULT_BANNER_SETTINGS.merge(banner_settings || {})
-
end
-
-
def geolocation_settings_with_defaults
-
DEFAULT_GEOLOCATION_SETTINGS.merge(geolocation_settings || {})
-
end
-
-
def pixel_consent_mapping_with_defaults
-
mapping = {}
-
consent_categories_with_defaults.each do |category, settings|
-
mapping[category] = settings['pixels'] || []
-
end
-
mapping.merge(pixel_consent_mapping || {})
-
end
-
-
def get_pixels_for_consent_category(category)
-
pixel_consent_mapping_with_defaults[category] || []
-
end
-
-
def get_consent_categories_for_pixel(pixel_type)
-
categories = []
-
pixel_consent_mapping_with_defaults.each do |category, pixels|
-
categories << category if pixels.include?(pixel_type)
-
end
-
categories
-
end
-
-
def is_pixel_consent_required?(pixel_type)
-
get_consent_categories_for_pixel(pixel_type).any? do |category|
-
settings = consent_categories_with_defaults[category]
-
settings && !settings['required'] && !settings['default_enabled']
-
end
-
end
-
-
def get_region_from_ip(ip_address)
-
return 'unknown' unless geolocation_settings_with_defaults['enabled']
-
-
begin
-
# Use MaxMind GeoIP or similar service
-
result = Geocoder.search(ip_address).first
-
return 'unknown' unless result
-
-
country_code = result.country_code&.upcase
-
return 'unknown' unless country_code
-
-
# Check EU countries
-
if geolocation_settings_with_defaults['eu_countries'].include?(country_code)
-
return 'eu'
-
end
-
-
# Check UK
-
if geolocation_settings_with_defaults['uk_countries'].include?(country_code)
-
return 'uk'
-
end
-
-
# Check Canada
-
if country_code == 'CA'
-
return 'ca'
-
end
-
-
# Check US
-
if country_code == 'US'
-
return 'us'
-
end
-
-
'other'
-
rescue => e
-
Rails.logger.error "Geolocation error: #{e.message}"
-
'unknown'
-
end
-
end
-
-
def get_consent_mode_for_region(region)
-
region_settings = geolocation_settings_with_defaults['region_specific_settings']
-
region_settings[region]&.dig('consent_mode') || geolocation_settings_with_defaults['fallback_consent_mode']
-
end
-
-
def should_show_banner?(region = nil, user_consent = nil)
-
return false unless banner_settings_with_defaults['enabled']
-
return false if user_consent&.any? { |consent| consent['consent_type'] == 'necessary' && consent['granted'] }
-
-
# Check if user has given any consent
-
if user_consent&.any? { |consent| consent['granted'] }
-
return false
-
end
-
-
# Region-specific logic
-
if region && region != 'unknown'
-
region_settings = geolocation_settings_with_defaults['region_specific_settings'][region]
-
return region_settings&.dig('require_explicit_consent') == true if region_settings
-
end
-
-
true
-
end
-
-
def generate_banner_html(region = nil, user_consent = nil)
-
return '' unless should_show_banner?(region, user_consent)
-
-
settings = banner_settings_with_defaults
-
categories = consent_categories_with_defaults
-
-
# Generate the banner HTML
-
<<~HTML
-
<div id="consent-banner" class="consent-banner" style="display: none;">
-
<div class="consent-banner-content">
-
<div class="consent-banner-header">
-
<h3 class="consent-banner-title">#{settings['text']['title']}</h3>
-
<button class="consent-banner-close" onclick="ConsentManager.hideBanner()">
-
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
-
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
-
</svg>
-
</button>
-
</div>
-
<div class="consent-banner-body">
-
<p class="consent-banner-description">#{settings['text']['description']}</p>
-
</div>
-
<div class="consent-banner-actions">
-
#{generate_banner_buttons(settings)}
-
</div>
-
</div>
-
</div>
-
<div id="consent-preferences-modal" class="consent-preferences-modal" style="display: none;">
-
<div class="consent-modal-content">
-
<div class="consent-modal-header">
-
<h3 class="consent-modal-title">Cookie Preferences</h3>
-
<button class="consent-modal-close" onclick="ConsentManager.hidePreferencesModal()">
-
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
-
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414 1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
-
</svg>
-
</button>
-
</div>
-
<div class="consent-modal-body">
-
#{generate_preferences_form(categories)}
-
</div>
-
<div class="consent-modal-actions">
-
<button class="consent-btn consent-btn-secondary" onclick="ConsentManager.hidePreferencesModal()">
-
#{settings['text']['close']}
-
</button>
-
<button class="consent-btn consent-btn-primary" onclick="ConsentManager.savePreferences()">
-
#{settings['text']['save_preferences']}
-
</button>
-
</div>
-
</div>
-
</div>
-
HTML
-
end
-
-
def generate_banner_css
-
settings = banner_settings_with_defaults
-
colors = settings['colors']
-
fonts = settings['fonts']
-
-
<<~CSS
-
.consent-banner {
-
position: fixed;
-
bottom: 0;
-
left: 0;
-
right: 0;
-
background: #{colors['background']};
-
color: #{colors['text']};
-
padding: 20px;
-
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1);
-
z-index: 9999;
-
font-family: #{fonts['family']};
-
transform: translateY(100%);
-
transition: transform #{settings['animation_duration']}ms ease-in-out;
-
}
-
-
.consent-banner.show {
-
transform: translateY(0);
-
}
-
-
.consent-banner-content {
-
max-width: 1200px;
-
margin: 0 auto;
-
display: flex;
-
flex-direction: column;
-
gap: 16px;
-
}
-
-
.consent-banner-header {
-
display: flex;
-
justify-content: space-between;
-
align-items: center;
-
}
-
-
.consent-banner-title {
-
font-size: #{fonts['size_title']};
-
font-weight: 600;
-
margin: 0;
-
color: #{colors['text']};
-
}
-
-
.consent-banner-close {
-
background: none;
-
border: none;
-
color: #{colors['text']};
-
cursor: pointer;
-
padding: 4px;
-
border-radius: 4px;
-
transition: background-color 0.2s;
-
}
-
-
.consent-banner-close:hover {
-
background-color: rgba(255, 255, 255, 0.1);
-
}
-
-
.consent-banner-description {
-
font-size: #{fonts['size_description']};
-
margin: 0;
-
line-height: 1.5;
-
color: #{colors['text']};
-
}
-
-
.consent-banner-actions {
-
display: flex;
-
gap: 12px;
-
flex-wrap: wrap;
-
}
-
-
.consent-btn {
-
padding: 10px 20px;
-
border: none;
-
border-radius: 6px;
-
font-size: #{fonts['size_button']};
-
font-weight: 500;
-
cursor: pointer;
-
transition: all 0.2s;
-
font-family: #{fonts['family']};
-
}
-
-
.consent-btn-primary {
-
background-color: #{colors['button_accept']};
-
color: white;
-
}
-
-
.consent-btn-primary:hover {
-
opacity: 0.9;
-
transform: translateY(-1px);
-
}
-
-
.consent-btn-secondary {
-
background-color: #{colors['button_reject']};
-
color: white;
-
}
-
-
.consent-btn-secondary:hover {
-
opacity: 0.9;
-
transform: translateY(-1px);
-
}
-
-
.consent-btn-neutral {
-
background-color: #{colors['button_neutral']};
-
color: white;
-
}
-
-
.consent-btn-neutral:hover {
-
opacity: 0.9;
-
transform: translateY(-1px);
-
}
-
-
.consent-preferences-modal {
-
position: fixed;
-
top: 0;
-
left: 0;
-
right: 0;
-
bottom: 0;
-
background-color: rgba(0, 0, 0, 0.5);
-
z-index: 10000;
-
display: flex;
-
align-items: center;
-
justify-content: center;
-
padding: 20px;
-
}
-
-
.consent-modal-content {
-
background: white;
-
border-radius: 8px;
-
max-width: 600px;
-
width: 100%;
-
max-height: 80vh;
-
overflow-y: auto;
-
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
-
}
-
-
.consent-modal-header {
-
padding: 20px;
-
border-bottom: 1px solid #e5e7eb;
-
display: flex;
-
justify-content: space-between;
-
align-items: center;
-
}
-
-
.consent-modal-title {
-
font-size: 18px;
-
font-weight: 600;
-
margin: 0;
-
color: #111827;
-
}
-
-
.consent-modal-close {
-
background: none;
-
border: none;
-
color: #6b7280;
-
cursor: pointer;
-
padding: 4px;
-
border-radius: 4px;
-
transition: background-color 0.2s;
-
}
-
-
.consent-modal-close:hover {
-
background-color: #f3f4f6;
-
}
-
-
.consent-modal-body {
-
padding: 20px;
-
}
-
-
.consent-modal-actions {
-
padding: 20px;
-
border-top: 1px solid #e5e7eb;
-
display: flex;
-
justify-content: flex-end;
-
gap: 12px;
-
}
-
-
.consent-category {
-
margin-bottom: 20px;
-
padding: 16px;
-
border: 1px solid #e5e7eb;
-
border-radius: 6px;
-
}
-
-
.consent-category-header {
-
display: flex;
-
justify-content: space-between;
-
align-items: center;
-
margin-bottom: 8px;
-
}
-
-
.consent-category-title {
-
font-size: 16px;
-
font-weight: 500;
-
margin: 0;
-
color: #111827;
-
}
-
-
.consent-category-description {
-
font-size: 14px;
-
color: #6b7280;
-
margin: 0;
-
line-height: 1.5;
-
}
-
-
.consent-toggle {
-
position: relative;
-
display: inline-block;
-
width: 44px;
-
height: 24px;
-
}
-
-
.consent-toggle input {
-
opacity: 0;
-
width: 0;
-
height: 0;
-
}
-
-
.consent-slider {
-
position: absolute;
-
cursor: pointer;
-
top: 0;
-
left: 0;
-
right: 0;
-
bottom: 0;
-
background-color: #ccc;
-
transition: 0.4s;
-
border-radius: 24px;
-
}
-
-
.consent-slider:before {
-
position: absolute;
-
content: "";
-
height: 18px;
-
width: 18px;
-
left: 3px;
-
bottom: 3px;
-
background-color: white;
-
transition: 0.4s;
-
border-radius: 50%;
-
}
-
-
.consent-toggle input:checked + .consent-slider {
-
background-color: #{colors['button_accept']};
-
}
-
-
.consent-toggle input:checked + .consent-slider:before {
-
transform: translateX(20px);
-
}
-
-
.consent-toggle input:disabled + .consent-slider {
-
background-color: #{colors['button_neutral']};
-
cursor: not-allowed;
-
}
-
-
@media (max-width: 768px) {
-
.consent-banner-actions {
-
flex-direction: column;
-
}
-
-
.consent-btn {
-
width: 100%;
-
}
-
-
.consent-modal-content {
-
margin: 10px;
-
}
-
}
-
-
#{settings['custom_css']}
-
CSS
-
end
-
-
private
-
-
def set_defaults
-
self.consent_categories ||= DEFAULT_CONSENT_CATEGORIES
-
self.banner_settings ||= DEFAULT_BANNER_SETTINGS
-
self.geolocation_settings ||= DEFAULT_GEOLOCATION_SETTINGS
-
self.active ||= true
-
end
-
-
def generate_banner_buttons(settings)
-
buttons = []
-
-
if settings['show_accept_all']
-
buttons << "<button class=\"consent-btn consent-btn-primary\" onclick=\"ConsentManager.acceptAll()\">#{settings['text']['accept_all']}</button>"
-
end
-
-
if settings['show_reject_all']
-
buttons << "<button class=\"consent-btn consent-btn-secondary\" onclick=\"ConsentManager.rejectAll()\">#{settings['text']['reject_all']}</button>"
-
end
-
-
if settings['show_necessary_only']
-
buttons << "<button class=\"consent-btn consent-btn-neutral\" onclick=\"ConsentManager.acceptNecessary()\">#{settings['text']['necessary_only']}</button>"
-
end
-
-
if settings['show_manage_preferences']
-
buttons << "<button class=\"consent-btn consent-btn-neutral\" onclick=\"ConsentManager.showPreferencesModal()\">#{settings['text']['manage_preferences']}</button>"
-
end
-
-
buttons.join('')
-
end
-
-
def generate_preferences_form(categories)
-
form_html = ''
-
-
categories.each do |category, settings|
-
required_class = settings['required'] ? 'required' : ''
-
disabled_attr = settings['required'] ? 'disabled' : ''
-
checked_attr = settings['default_enabled'] ? 'checked' : ''
-
-
form_html += <<~HTML
-
<div class="consent-category #{required_class}">
-
<div class="consent-category-header">
-
<h4 class="consent-category-title">#{settings['name']}</h4>
-
<label class="consent-toggle">
-
<input type="checkbox" #{checked_attr} #{disabled_attr} data-category="#{category}">
-
<span class="consent-slider"></span>
-
</label>
-
</div>
-
<p class="consent-category-description">#{settings['description']}</p>
-
</div>
-
HTML
-
end
-
-
form_html
-
end
-
end
-
class ContentType < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Associations
-
has_many :posts, dependent: :nullify
-
-
# Validations
-
validates :ident, presence: true, uniqueness: true, format: { with: /\A[a-z0-9_-]+\z/, message: "only allows lowercase letters, numbers, hyphens, and underscores" }
-
validates :label, presence: true
-
validates :singular, presence: true
-
validates :plural, presence: true
-
-
# JSON fields
-
attribute :supports, :json, default: -> { ['title', 'editor', 'excerpt', 'thumbnail', 'comments'] }
-
attribute :capabilities, :json, default: -> { {} }
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :public_types, -> { where(public: true) }
-
scope :ordered, -> { order(:menu_position, :label) }
-
-
# Callbacks
-
before_validation :set_defaults, on: :create
-
before_validation :normalize_ident
-
-
# Class methods
-
def self.find_by_ident(ident)
-
find_by(ident: ident.to_s.downcase.strip)
-
end
-
-
def self.default_type
-
find_by_ident('post') || first
-
end
-
-
# Instance methods
-
def to_param
-
ident
-
end
-
-
def display_name
-
label
-
end
-
-
def supports?(feature)
-
supports.is_a?(Array) && supports.include?(feature.to_s)
-
end
-
-
def add_support(feature)
-
self.supports ||= []
-
self.supports << feature.to_s unless supports?(feature)
-
self.supports = supports.uniq
-
end
-
-
def remove_support(feature)
-
self.supports ||= []
-
self.supports.delete(feature.to_s)
-
end
-
-
def can?(capability)
-
capabilities.is_a?(Hash) && capabilities[capability.to_s]
-
end
-
-
def rest_endpoint
-
rest_base.presence || ident.pluralize
-
end
-
-
private
-
-
def set_defaults
-
self.rest_base ||= ident&.pluralize
-
self.singular ||= label
-
self.plural ||= label&.pluralize
-
self.icon ||= 'document-text'
-
self.public = true if public.nil?
-
self.active = true if active.nil?
-
self.hierarchical = false if hierarchical.nil?
-
self.has_archive = true if has_archive.nil?
-
end
-
-
def normalize_ident
-
self.ident = ident.to_s.downcase.strip.gsub(/[^a-z0-9_-]/, '-') if ident.present?
-
end
-
end
-
class Current < ActiveSupport::CurrentAttributes
-
attribute :user
-
end
-
-
-
-
-
class CustomField < ApplicationRecord
-
# Associations
-
belongs_to :field_group
-
has_many :custom_field_values, dependent: :destroy
-
-
# Serialization
-
serialize :choices, coder: JSON, type: Hash
-
serialize :conditional_logic, coder: JSON, type: Hash
-
serialize :settings, coder: JSON, type: Hash
-
-
# Validations
-
validates :name, presence: true
-
validates :label, presence: true
-
validates :field_type, presence: true, inclusion: { in: FieldGroup::FIELD_TYPES.keys }
-
-
# Callbacks
-
before_validation :normalize_name
-
-
# Scopes
-
scope :ordered, -> { order(position: :asc) }
-
scope :required_fields, -> { where(required: true) }
-
scope :by_type, ->(type) { where(field_type: type) }
-
-
# Get formatted choices for select/radio/checkbox fields
-
def formatted_choices
-
return [] if choices.blank?
-
-
if choices.is_a?(Hash)
-
choices.map { |k, v| [v, k] }
-
elsif choices.is_a?(Array)
-
choices.map { |c| [c, c] }
-
else
-
[]
-
end
-
end
-
-
# Check if field should be shown based on conditional logic
-
def should_show?(values = {})
-
return true if conditional_logic.blank?
-
-
logic = conditional_logic.is_a?(String) ? JSON.parse(conditional_logic) : conditional_logic
-
return true if logic.blank? || logic['rules'].blank?
-
-
operator = logic['operator'] || 'and' # 'and' or 'or'
-
rules = logic['rules']
-
-
results = rules.map do |rule|
-
field_name = rule['field']
-
condition = rule['operator'] # '==', '!=', 'contains', etc.
-
expected_value = rule['value']
-
-
actual_value = values[field_name].to_s
-
-
case condition
-
when '=='
-
actual_value == expected_value.to_s
-
when '!='
-
actual_value != expected_value.to_s
-
when 'contains'
-
actual_value.include?(expected_value.to_s)
-
when 'not_contains'
-
!actual_value.include?(expected_value.to_s)
-
when 'empty'
-
actual_value.blank?
-
when 'not_empty'
-
actual_value.present?
-
else
-
true
-
end
-
end
-
-
if operator == 'and'
-
results.all?
-
else # 'or'
-
results.any?
-
end
-
rescue
-
true # Show by default if logic is invalid
-
end
-
-
# Get setting value
-
def get_setting(key, default = nil)
-
return default if settings.blank?
-
settings[key.to_s] || default
-
end
-
-
# Field type helpers
-
def text_field?
-
%w[text email url password].include?(field_type)
-
end
-
-
def textarea_field?
-
field_type == 'textarea'
-
end
-
-
def number_field?
-
field_type == 'number'
-
end
-
-
def wysiwyg_field?
-
field_type == 'wysiwyg'
-
end
-
-
def select_field?
-
%w[select checkbox radio button_group].include?(field_type)
-
end
-
-
def boolean_field?
-
field_type == 'true_false'
-
end
-
-
def date_field?
-
%w[date_picker date_time_picker time_picker].include?(field_type)
-
end
-
-
def image_field?
-
%w[image file gallery].include?(field_type)
-
end
-
-
def relational_field?
-
%w[post_object page_link relationship taxonomy user].include?(field_type)
-
end
-
-
def repeater_field?
-
%w[repeater flexible_content group].include?(field_type)
-
end
-
-
private
-
-
def normalize_name
-
return if name.blank?
-
self.name = name.parameterize(separator: '_')
-
end
-
end
-
class CustomFieldValue < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Associations
-
belongs_to :custom_field
-
belongs_to :post, optional: true
-
belongs_to :page, optional: true
-
-
# Validations
-
validates :meta_key, presence: true
-
validate :must_belong_to_post_or_page
-
-
# Scopes
-
scope :for_post, ->(post_id) { where(post_id: post_id) }
-
scope :for_page, ->(page_id) { where(page_id: page_id) }
-
scope :by_key, ->(key) { where(meta_key: key) }
-
-
# Get typed value based on field type
-
def typed_value
-
return nil if value.blank?
-
-
case custom_field&.field_type
-
when 'number'
-
value.to_f
-
when 'true_false'
-
value.to_s == '1' || value.to_s.downcase == 'true'
-
when 'checkbox'
-
value.is_a?(String) ? JSON.parse(value) : value
-
when 'repeater', 'flexible_content', 'group'
-
value.is_a?(String) ? JSON.parse(value) : value
-
when 'gallery'
-
value.is_a?(String) ? JSON.parse(value) : value
-
else
-
value
-
end
-
rescue JSON::ParserError
-
value
-
end
-
-
# Set value with automatic serialization
-
def typed_value=(val)
-
case custom_field&.field_type
-
when 'checkbox', 'repeater', 'flexible_content', 'group', 'gallery'
-
self.value = val.is_a?(String) ? val : val.to_json
-
when 'true_false'
-
self.value = val.to_s == '1' || val.to_s.downcase == 'true' ? '1' : '0'
-
else
-
self.value = val.to_s
-
end
-
end
-
-
private
-
-
def must_belong_to_post_or_page
-
if post_id.blank? && page_id.blank?
-
errors.add(:base, 'Must belong to either a post or a page')
-
end
-
-
if post_id.present? && page_id.present?
-
errors.add(:base, 'Cannot belong to both a post and a page')
-
end
-
end
-
end
-
class CustomFont < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Versioning
-
has_paper_trail
-
-
# Serialization
-
serialize :weights, coder: JSON, type: Array
-
serialize :styles, coder: JSON, type: Array
-
-
# Validations
-
validates :name, presence: true, uniqueness: { scope: :tenant_id }
-
validates :family, presence: true
-
validates :source, presence: true, inclusion: { in: %w[google custom adobe bunny] }
-
validates :url, presence: true, if: -> { source == 'custom' }
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :google_fonts, -> { where(source: 'google') }
-
scope :custom_fonts, -> { where(source: 'custom') }
-
scope :ordered, -> { order(name: :asc) }
-
-
# Font sources
-
SOURCES = {
-
'google' => 'Google Fonts',
-
'custom' => 'Custom Font (Self-Hosted)',
-
'adobe' => 'Adobe Fonts',
-
'bunny' => 'Bunny Fonts (Privacy-Friendly)'
-
}.freeze
-
-
# Common fallback fonts
-
FALLBACKS = {
-
'sans-serif' => 'Sans Serif',
-
'serif' => 'Serif',
-
'monospace' => 'Monospace',
-
'cursive' => 'Cursive',
-
'fantasy' => 'Fantasy'
-
}.freeze
-
-
# Font weights
-
WEIGHTS = {
-
'100' => 'Thin',
-
'200' => 'Extra Light',
-
'300' => 'Light',
-
'400' => 'Regular',
-
'500' => 'Medium',
-
'600' => 'Semi Bold',
-
'700' => 'Bold',
-
'800' => 'Extra Bold',
-
'900' => 'Black'
-
}.freeze
-
-
# Font styles
-
STYLES = {
-
'normal' => 'Normal',
-
'italic' => 'Italic'
-
}.freeze
-
-
# Generate CSS @font-face rule for custom fonts
-
def to_css
-
return '' unless source == 'custom' && url.present?
-
-
css = "@font-face {\n"
-
css += " font-family: '#{family}';\n"
-
css += " src: url('#{url}');\n"
-
css += " font-display: swap;\n"
-
-
# Add weight and style if specified
-
if weights.present? && weights.first
-
css += " font-weight: #{weights.first};\n"
-
end
-
-
if styles.present? && styles.first
-
css += " font-style: #{styles.first};\n"
-
end
-
-
css += "}\n"
-
css
-
end
-
-
# Generate Google Fonts URL
-
def google_fonts_url
-
return '' unless source == 'google'
-
-
# Build Google Fonts API URL
-
base_url = "https://fonts.googleapis.com/css2?"
-
-
# Family with weights
-
family_param = "family=#{family.gsub(' ', '+')}"
-
-
if weights.present? && weights.any?
-
weights_str = weights.map { |w| "#{w}" }.join(';')
-
-
if styles.present? && styles.include?('italic')
-
# Include italic variants
-
weights_str = weights.map { |w| "0,#{w};1,#{w}" }.join(';')
-
family_param += ":ital,wght@#{weights_str}"
-
else
-
family_param += ":wght@#{weights.join(';')}"
-
end
-
end
-
-
"#{base_url}#{family_param}&display=swap"
-
end
-
-
# Generate Bunny Fonts URL (privacy-friendly Google Fonts alternative)
-
def bunny_fonts_url
-
return '' unless source == 'bunny'
-
-
# Bunny Fonts uses same API as Google Fonts
-
google_fonts_url.gsub('fonts.googleapis.com', 'fonts.bunny.net')
-
.gsub('fonts.gstatic.com', 'fonts.bunny.net')
-
end
-
-
# Get the appropriate URL based on source
-
def font_url
-
case source
-
when 'google'
-
google_fonts_url
-
when 'bunny'
-
bunny_fonts_url
-
when 'adobe'
-
url # Adobe Fonts provides direct URL
-
when 'custom'
-
url
-
else
-
''
-
end
-
end
-
-
# Generate CSS link tag
-
def to_link_tag
-
return to_css if source == 'custom'
-
-
url = font_url
-
return '' if url.blank?
-
-
"<link rel=\"preconnect\" href=\"#{preconnect_url}\">\n" \
-
"<link href=\"#{url}\" rel=\"stylesheet\">"
-
end
-
-
# Font stack for CSS (includes fallbacks)
-
def font_stack
-
"'#{family}', #{fallback}"
-
end
-
-
private
-
-
def preconnect_url
-
case source
-
when 'google'
-
'https://fonts.googleapis.com'
-
when 'bunny'
-
'https://fonts.bunny.net'
-
else
-
''
-
end
-
end
-
end
-
class EmailLog < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Serialize metadata as JSON
-
serialize :metadata, coder: JSON, type: Hash
-
-
# Enums
-
enum status: {
-
pending: 'pending',
-
sent: 'sent',
-
failed: 'failed',
-
bounced: 'bounced'
-
}, _prefix: true
-
-
enum provider: {
-
smtp: 'smtp',
-
resend: 'resend',
-
test: 'test'
-
}, _prefix: true
-
-
# Validations
-
validates :from_address, :to_address, :subject, presence: true
-
validates :status, presence: true
-
-
# Scopes
-
scope :recent, -> { order(created_at: :desc) }
-
scope :today, -> { where('created_at >= ?', Time.current.beginning_of_day) }
-
scope :this_week, -> { where('created_at >= ?', Time.current.beginning_of_week) }
-
scope :this_month, -> { where('created_at >= ?', Time.current.beginning_of_month) }
-
-
# Class methods
-
def self.log_email(from:, to:, subject:, body:, provider:, status: 'pending', error: nil, metadata: {})
-
create!(
-
from_address: from,
-
to_address: to,
-
subject: subject,
-
body: body,
-
provider: provider,
-
status: status,
-
error_message: error,
-
metadata: metadata,
-
sent_at: status == 'sent' ? Time.current : nil
-
)
-
end
-
-
def self.stats
-
{
-
total: count,
-
sent: status_sent.count,
-
failed: status_failed.count,
-
pending: status_pending.count,
-
today: today.count,
-
this_week: this_week.count,
-
this_month: this_month.count
-
}
-
end
-
-
# Instance methods
-
def success?
-
status_sent?
-
end
-
-
def failed?
-
status_failed? || status_bounced?
-
end
-
-
def truncated_body(length = 200)
-
return '' if body.blank?
-
body.truncate(length)
-
end
-
end
-
class ExportJob < ApplicationRecord
-
acts_as_tenant(:tenant)
-
-
belongs_to :user
-
-
validates :export_type, presence: true
-
validates :status, presence: true
-
-
enum status: {
-
pending: 'pending',
-
processing: 'processing',
-
completed: 'completed',
-
failed: 'failed'
-
}, _suffix: true
-
-
scope :recent, -> { order(created_at: :desc) }
-
scope :active, -> { where(status: ['pending', 'processing']) }
-
end
-
class FieldGroup < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Versioning
-
has_paper_trail
-
-
# Associations
-
has_many :custom_fields, dependent: :destroy
-
accepts_nested_attributes_for :custom_fields, allow_destroy: true
-
-
# Serialization
-
serialize :location_rules, coder: JSON, type: Hash
-
-
# Validations
-
validates :name, presence: true
-
validates :slug, presence: true, uniqueness: { scope: :tenant_id }
-
-
# Callbacks
-
before_validation :generate_slug, if: -> { slug.blank? }
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :ordered, -> { order(position: :asc) }
-
scope :for_posts, -> { where("location_rules LIKE '%post%'") }
-
scope :for_pages, -> { where("location_rules LIKE '%page%'") }
-
-
# ACF-style field types
-
FIELD_TYPES = {
-
# Basic
-
'text' => 'Text',
-
'textarea' => 'Text Area',
-
'number' => 'Number',
-
'email' => 'Email',
-
'url' => 'URL',
-
'password' => 'Password',
-
-
# Content
-
'wysiwyg' => 'WYSIWYG Editor',
-
'oembed' => 'oEmbed',
-
'image' => 'Image',
-
'file' => 'File',
-
'gallery' => 'Gallery',
-
-
# Choice
-
'select' => 'Select',
-
'checkbox' => 'Checkbox',
-
'radio' => 'Radio Button',
-
'button_group' => 'Button Group',
-
'true_false' => 'True / False',
-
-
# Relational
-
'link' => 'Link',
-
'post_object' => 'Post Object',
-
'page_link' => 'Page Link',
-
'relationship' => 'Relationship',
-
'taxonomy' => 'Taxonomy',
-
'user' => 'User',
-
-
# jQuery
-
'date_picker' => 'Date Picker',
-
'date_time_picker' => 'Date Time Picker',
-
'time_picker' => 'Time Picker',
-
'color_picker' => 'Color Picker',
-
-
# Layout
-
'message' => 'Message',
-
'accordion' => 'Accordion',
-
'tab' => 'Tab',
-
'group' => 'Group',
-
'repeater' => 'Repeater',
-
'flexible_content' => 'Flexible Content'
-
}.freeze
-
-
# Location rules for where to show this field group
-
def self.location_rule_operators
-
{
-
'==' => 'is equal to',
-
'!=' => 'is not equal to',
-
'contains' => 'contains',
-
'not_contains' => 'does not contain'
-
}
-
end
-
-
def self.location_rule_params
-
{
-
'post_type' => 'Post Type',
-
'post_category' => 'Post Category',
-
'post_status' => 'Post Status',
-
'page_type' => 'Page Type',
-
'page_parent' => 'Page Parent',
-
'page_template' => 'Page Template',
-
'current_user_role' => 'Current User Role'
-
}
-
end
-
-
# Check if this field group should be shown for a given object
-
def matches_location?(object)
-
return true if location_rules.blank?
-
-
rules = location_rules.is_a?(String) ? JSON.parse(location_rules) : location_rules
-
return true if rules.blank?
-
-
# All rules must match (AND logic)
-
rules.all? do |rule|
-
param = rule['param']
-
operator = rule['operator']
-
value = rule['value']
-
-
check_location_rule(object, param, operator, value)
-
end
-
rescue
-
true # Show by default if rules are invalid
-
end
-
-
private
-
-
def generate_slug
-
self.slug = name.parameterize
-
end
-
-
def check_location_rule(object, param, operator, value)
-
case param
-
when 'post_type'
-
return false unless object.is_a?(Post)
-
compare_values(
-
-
'post', operator, value)
-
when 'post_category'
-
return false unless object.is_a?(Post)
-
category_taxonomy = Taxonomy.find_by(slug: 'category')
-
return false unless category_taxonomy
-
categories = object.terms.where(taxonomy: category_taxonomy).pluck(:id).map(&:to_s)
-
compare_values(categories, operator, value)
-
when 'page_type'
-
return false unless object.is_a?(Page)
-
compare_values('page', operator, value)
-
else
-
true
-
end
-
end
-
-
def compare_values(actual, operator, expected)
-
case operator
-
when '=='
-
if actual.is_a?(Array)
-
actual.include?(expected)
-
else
-
actual.to_s == expected.to_s
-
end
-
when '!='
-
if actual.is_a?(Array)
-
!actual.include?(expected)
-
else
-
actual.to_s != expected.to_s
-
end
-
when 'contains'
-
actual.to_s.include?(expected.to_s)
-
when 'not_contains'
-
!actual.to_s.include?(expected.to_s)
-
else
-
true
-
end
-
end
-
end
-
class ImageOptimizationLog < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Associations
-
belongs_to :medium
-
belongs_to :upload
-
belongs_to :user
-
-
# Serialization
-
serialize :variants_generated, coder: JSON, type: Array
-
serialize :responsive_variants_generated, coder: JSON, type: Array
-
serialize :warnings, coder: JSON, type: Array
-
-
# Validations
-
validates :compression_level, presence: true
-
validates :status, presence: true, inclusion: { in: %w[success failed skipped partial] }
-
validates :optimization_type, presence: true, inclusion: { in: %w[upload bulk manual regenerate] }
-
validates :original_size, presence: true, numericality: { greater_than: 0 }
-
validates :optimized_size, presence: true, numericality: { greater_than: 0 }
-
validates :quality, presence: true, numericality: { in: 1..100 }
-
validates :processing_time, presence: true, numericality: { greater_than: 0 }
-
-
# Scopes
-
scope :successful, -> { where(status: 'success') }
-
scope :failed, -> { where(status: 'failed') }
-
scope :skipped, -> { where(status: 'skipped') }
-
scope :partial, -> { where(status: 'partial') }
-
-
scope :by_compression_level, ->(level) { where(compression_level: level) }
-
scope :by_optimization_type, ->(type) { where(optimization_type: type) }
-
scope :by_user, ->(user) { where(user: user) }
-
scope :by_tenant, ->(tenant) { where(tenant: tenant) }
-
-
scope :recent, -> { order(created_at: :desc) }
-
scope :today, -> { where(created_at: Date.current.all_day) }
-
scope :this_week, -> { where(created_at: Date.current.beginning_of_week..Date.current.end_of_week) }
-
scope :this_month, -> { where(created_at: Date.current.beginning_of_month..Date.current.end_of_month) }
-
-
# Callbacks
-
before_validation :calculate_metrics
-
-
# Class methods for analytics
-
def self.total_images_optimized
-
successful.count
-
end
-
-
def self.total_bytes_saved
-
successful.sum(:bytes_saved)
-
end
-
-
def self.total_processing_time
-
successful.sum(:processing_time)
-
end
-
-
def self.average_size_reduction
-
successful.average(:size_reduction_percentage)
-
end
-
-
def self.average_processing_time
-
successful.average(:processing_time)
-
end
-
-
def self.compression_level_stats
-
successful.group(:compression_level).count
-
end
-
-
def self.optimization_type_stats
-
successful.group(:optimization_type).count
-
end
-
-
def self.daily_stats(days = 30)
-
successful.where(created_at: days.days.ago..Time.current)
-
.group("DATE(created_at)")
-
.count
-
end
-
-
def self.user_stats
-
successful.group(:user_id).count
-
end
-
-
def self.tenant_stats
-
successful.group(:tenant_id).count
-
end
-
-
def self.top_savings(limit = 10)
-
successful.order(bytes_saved: :desc).limit(limit)
-
end
-
-
def self.failed_optimizations
-
failed.includes(:medium, :upload, :user)
-
end
-
-
# Instance methods
-
def success?
-
status == 'success'
-
end
-
-
def failed?
-
status == 'failed'
-
end
-
-
def skipped?
-
status == 'skipped'
-
end
-
-
def partial?
-
status == 'partial'
-
end
-
-
def size_reduction_mb
-
(bytes_saved / 1024.0 / 1024.0).round(2)
-
end
-
-
def original_size_mb
-
(original_size / 1024.0 / 1024.0).round(2)
-
end
-
-
def optimized_size_mb
-
(optimized_size / 1024.0 / 1024.0).round(2)
-
end
-
-
def processing_time_formatted
-
if processing_time < 1
-
"#{(processing_time * 1000).round(0)}ms"
-
else
-
"#{processing_time.round(2)}s"
-
end
-
end
-
-
def compression_level_name
-
ImageOptimizationService.available_compression_levels[compression_level]&.dig(:name) || compression_level.capitalize
-
end
-
-
def compression_level_description
-
ImageOptimizationService.available_compression_levels[compression_level]&.dig(:description) || 'Custom settings'
-
end
-
-
def expected_savings
-
ImageOptimizationService.available_compression_levels[compression_level]&.dig(:expected_savings) || 'Variable'
-
end
-
-
def recommended_for
-
ImageOptimizationService.available_compression_levels[compression_level]&.dig(:recommended_for) || 'Advanced users'
-
end
-
-
# Status check methods
-
def success?
-
status == 'success'
-
end
-
-
def failed?
-
status == 'failed'
-
end
-
-
def skipped?
-
status == 'skipped'
-
end
-
-
def partial?
-
status == 'partial'
-
end
-
-
# Size and time formatting methods
-
def size_reduction_mb
-
(bytes_saved / 1024.0 / 1024.0).round(2)
-
end
-
-
def processing_time_formatted
-
if processing_time < 1
-
"#{(processing_time * 1000).round(0)}ms"
-
else
-
"#{processing_time.round(2)}s"
-
end
-
end
-
-
# Compression level info methods
-
def compression_level_name
-
ImageOptimizationService.available_compression_levels[compression_level]&.dig(:name) || compression_level.capitalize
-
end
-
-
def compression_level_description
-
ImageOptimizationService.available_compression_levels[compression_level]&.dig(:description) || 'Custom settings'
-
end
-
-
# API response method
-
def api_response
-
{
-
id: id,
-
filename: filename,
-
content_type: content_type,
-
original_size: original_size,
-
optimized_size: optimized_size,
-
bytes_saved: bytes_saved,
-
size_reduction_percentage: size_reduction_percentage,
-
size_reduction_mb: size_reduction_mb,
-
compression_level: compression_level,
-
compression_level_name: compression_level_name,
-
quality: quality,
-
processing_time: processing_time,
-
processing_time_formatted: processing_time_formatted,
-
status: status,
-
optimization_type: optimization_type,
-
variants_generated: variants_generated,
-
responsive_variants_generated: responsive_variants_generated,
-
error_message: error_message,
-
warnings: warnings,
-
user: {
-
id: user_id,
-
email: user&.email
-
},
-
medium: {
-
id: medium_id,
-
title: medium&.title
-
},
-
upload: {
-
id: upload_id,
-
title: upload&.title
-
},
-
created_at: created_at,
-
updated_at: updated_at
-
}
-
end
-
-
# Analytics methods
-
def self.generate_report(start_date = 30.days.ago, end_date = Time.current)
-
logs = where(created_at: start_date..end_date)
-
-
{
-
total_optimizations: logs.count,
-
successful_optimizations: logs.successful.count,
-
failed_optimizations: logs.failed.count,
-
skipped_optimizations: logs.skipped.count,
-
total_bytes_saved: logs.successful.sum(:bytes_saved),
-
total_size_saved_mb: (logs.successful.sum(:bytes_saved) / 1024.0 / 1024.0).round(2),
-
average_size_reduction: logs.successful.average(:size_reduction_percentage)&.round(2),
-
average_processing_time: logs.successful.average(:processing_time)&.round(3),
-
compression_level_breakdown: logs.successful.group(:compression_level).count,
-
optimization_type_breakdown: logs.successful.group(:optimization_type).count,
-
daily_optimizations: logs.successful.group("DATE(created_at)").count,
-
top_users: logs.successful.group(:user_id).count.sort_by { |_, count| -count }.first(10),
-
top_tenants: logs.successful.group(:tenant_id).count.sort_by { |_, count| -count }.first(10)
-
}
-
end
-
-
def self.export_to_csv(start_date = 30.days.ago, end_date = Time.current)
-
require 'csv'
-
-
logs = where(created_at: start_date..end_date).includes(:medium, :upload, :user, :tenant)
-
-
CSV.generate do |csv|
-
csv << [
-
'Date', 'User', 'Tenant', 'Filename', 'Content Type', 'Original Size (MB)',
-
'Optimized Size (MB)', 'Bytes Saved', 'Size Reduction %', 'Compression Level',
-
'Quality', 'Processing Time', 'Status', 'Optimization Type', 'Variants Generated',
-
'Responsive Variants', 'Storage Provider', 'CDN Enabled', 'Error Message'
-
]
-
-
logs.each do |log|
-
csv << [
-
log.created_at.strftime('%Y-%m-%d %H:%M:%S'),
-
log.user&.email || 'Unknown',
-
log.tenant&.name || 'Unknown',
-
log.filename,
-
log.content_type,
-
log.original_size_mb,
-
log.optimized_size_mb,
-
log.bytes_saved,
-
log.size_reduction_percentage,
-
log.compression_level,
-
log.quality,
-
log.processing_time_formatted,
-
log.status,
-
log.optimization_type,
-
log.variants_generated&.join(', ') || '',
-
log.responsive_variants_generated&.join(', ') || '',
-
log.storage_provider,
-
log.cdn_enabled ? 'Yes' : 'No',
-
log.error_message || ''
-
]
-
end
-
end
-
end
-
-
private
-
-
def calculate_metrics
-
return unless original_size && optimized_size
-
-
self.bytes_saved = original_size - optimized_size
-
self.size_reduction_percentage = ((bytes_saved.to_f / original_size) * 100).round(2)
-
end
-
end
-
class ImportJob < ApplicationRecord
-
acts_as_tenant(:tenant)
-
-
belongs_to :user
-
-
validates :import_type, presence: true
-
validates :file_path, presence: true
-
validates :status, presence: true
-
-
enum status: {
-
pending: 'pending',
-
processing: 'processing',
-
completed: 'completed',
-
failed: 'failed'
-
}, _suffix: true
-
-
scope :recent, -> { order(created_at: :desc) }
-
scope :active, -> { where(status: ['pending', 'processing']) }
-
end
-
class Medium < ApplicationRecord
-
include Railspress::ChannelDetection
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Trash functionality
-
include Trashable
-
-
belongs_to :user
-
belongs_to :upload
-
-
# Channels
-
has_and_belongs_to_many :channels
-
-
# Validations
-
validates :title, presence: true
-
-
# Callbacks
-
after_create :trigger_media_uploaded_hook
-
-
# Scopes
-
scope :images, -> { joins(:upload).merge(Upload.images) }
-
scope :videos, -> { joins(:upload).merge(Upload.videos) }
-
scope :documents, -> { joins(:upload).merge(Upload.documents) }
-
scope :recent, -> { order(created_at: :desc) }
-
scope :approved, -> { joins(:upload).merge(Upload.approved) }
-
scope :quarantined, -> { joins(:upload).merge(Upload.quarantined) }
-
-
# File methods - delegate to upload
-
def image?
-
upload&.image?
-
end
-
-
def video?
-
upload&.video?
-
end
-
-
def document?
-
upload&.document?
-
end
-
-
def file_size
-
upload&.file_size || 0
-
end
-
-
def content_type
-
upload&.content_type
-
end
-
-
def filename
-
upload&.filename
-
end
-
-
def url
-
upload&.url
-
end
-
-
def file_attached?
-
upload&.file&.attached?
-
end
-
-
def quarantined?
-
upload&.quarantined?
-
end
-
-
def approved?
-
upload&.approved?
-
end
-
-
def quarantine_reason
-
upload&.quarantine_reason
-
end
-
-
# API serialization helpers
-
def api_attributes
-
{
-
id: id,
-
title: title,
-
description: description,
-
alt_text: alt_text,
-
filename: filename,
-
content_type: content_type,
-
file_size: file_size,
-
url: url,
-
image: image?,
-
video: video?,
-
document: document?,
-
quarantined: quarantined?,
-
quarantine_reason: quarantine_reason,
-
created_at: created_at,
-
updated_at: updated_at,
-
user: {
-
id: user.id,
-
name: user.name,
-
email: user.email
-
},
-
upload: {
-
id: upload.id,
-
title: upload.title,
-
storage_provider: {
-
id: upload.storage_provider.id,
-
name: upload.storage_provider.name,
-
type: upload.storage_provider.provider_type
-
}
-
}
-
}
-
end
-
-
# Class methods for API
-
def self.with_file_info
-
includes(:upload, :user, upload: :storage_provider)
-
end
-
-
def self.by_type(type)
-
case type.to_s
-
when 'image'
-
images
-
when 'video'
-
videos
-
when 'document'
-
documents
-
else
-
all
-
end
-
end
-
-
def trigger_media_uploaded_hook
-
# Trigger plugin hooks
-
Railspress::PluginSystem.do_action('media_uploaded', self)
-
-
# Core image optimization (baked into system)
-
optimize_image_if_needed
-
end
-
-
# Core image optimization method
-
def optimize_image_if_needed
-
return unless image?
-
return unless upload&.file&.attached?
-
-
# Check if optimization is enabled in settings
-
storage_config = StorageConfigurationService.new
-
return unless storage_config.auto_optimize_enabled?
-
-
# Check media settings
-
return unless SiteSetting.get('auto_optimize_images', false)
-
-
# Queue optimization job
-
OptimizeImageJob.perform_later(medium_id: id)
-
-
Rails.logger.info "Queued image optimization for medium #{id} (core system)"
-
end
-
-
private
-
end
-
class Menu < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Associations
-
has_many :menu_items, dependent: :destroy
-
-
# Validations
-
validates :name, presence: true, uniqueness: true
-
validates :location, presence: true
-
-
# Scopes
-
scope :by_location, ->(location) { where(location: location) }
-
-
# Methods
-
def root_items
-
menu_items.where(parent_id: nil).order(position: :asc)
-
end
-
end
-
class MenuItem < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
belongs_to :menu
-
belongs_to :parent, class_name: 'MenuItem', optional: true
-
-
# Hierarchical structure
-
has_many :children, class_name: 'MenuItem', foreign_key: 'parent_id', dependent: :destroy
-
-
# Validations
-
validates :label, presence: true
-
validates :url, presence: true
-
validates :position, presence: true, numericality: { only_integer: true }
-
-
# Scopes
-
scope :ordered, -> { order(position: :asc) }
-
scope :root_items, -> { where(parent_id: nil) }
-
-
# Callbacks
-
before_validation :set_position, on: :create
-
-
private
-
-
def set_position
-
return unless menu.present?
-
self.position ||= (menu.menu_items.maximum(:position) || 0) + 1
-
end
-
end
-
class MetaField < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant, optional: true)
-
-
belongs_to :metable, polymorphic: true
-
-
# Validations
-
validates :key, presence: true, length: { maximum: 255 }
-
validates :key, uniqueness: { scope: [:metable_type, :metable_id], message: "must be unique per metable" }
-
validates :immutable, inclusion: { in: [true, false] }
-
-
# Scopes
-
scope :immutable, -> { where(immutable: true) }
-
scope :mutable, -> { where(immutable: false) }
-
scope :by_key, ->(key) { where(key: key) }
-
-
# Callbacks for cache invalidation
-
after_save :invalidate_metable_cache
-
after_destroy :invalidate_metable_cache
-
-
# Class methods for easy access
-
def self.get(metable, key)
-
cache_key = "meta_field:#{metable.class.name}:#{metable.id}:#{key}"
-
-
Rails.cache.fetch(cache_key, expires_in: 1.hour) do
-
find_by(metable: metable, key: key)&.value
-
end
-
end
-
-
def self.set(metable, key, value, immutable: false)
-
meta_field = find_or_initialize_by(metable: metable, key: key)
-
-
if meta_field.persisted? && meta_field.immutable?
-
raise ArgumentError, "Cannot modify immutable meta field: #{key}"
-
end
-
-
meta_field.assign_attributes(value: value, immutable: immutable)
-
meta_field.save!
-
-
# Update cache
-
cache_key = "meta_field:#{metable.class.name}:#{metable.id}:#{key}"
-
Rails.cache.write(cache_key, value, expires_in: 1.hour)
-
-
meta_field
-
end
-
-
def self.delete(metable, key)
-
meta_field = find_by(metable: metable, key: key)
-
-
if meta_field&.immutable?
-
raise ArgumentError, "Cannot delete immutable meta field: #{key}"
-
end
-
-
if meta_field&.destroy
-
# Clear cache
-
cache_key = "meta_field:#{metable.class.name}:#{metable.id}:#{key}"
-
Rails.cache.delete(cache_key)
-
-
# Clear metable's meta cache
-
metable_cache_key = "meta_fields:#{metable.class.name}:#{metable.id}"
-
Rails.cache.delete(metable_cache_key)
-
end
-
-
meta_field
-
end
-
-
def self.bulk_get(metable, keys)
-
cache_keys = keys.map { |key| "meta_field:#{metable.class.name}:#{metable.id}:#{key}" }
-
-
cached_values = Rails.cache.read_multi(*cache_keys)
-
missing_keys = keys - cached_values.keys.map { |k| k.split(':').last }
-
-
if missing_keys.any?
-
# Fetch missing values from database
-
missing_meta_fields = where(metable: metable, key: missing_keys)
-
-
missing_meta_fields.each do |meta_field|
-
cache_key = "meta_field:#{metable.class.name}:#{metable.id}:#{meta_field.key}"
-
Rails.cache.write(cache_key, meta_field.value, expires_in: 1.hour)
-
cached_values[meta_field.key] = meta_field.value
-
end
-
end
-
-
# Return values in the same order as requested keys
-
keys.map { |key| cached_values[key] }
-
end
-
-
def self.bulk_set(metable, hash, immutable: false)
-
transaction do
-
hash.each do |key, value|
-
set(metable, key, value, immutable: immutable)
-
end
-
end
-
-
# Clear metable's meta cache
-
metable_cache_key = "meta_fields:#{metable.class.name}:#{metable.id}"
-
Rails.cache.delete(metable_cache_key)
-
end
-
-
def self.all_for(metable)
-
cache_key = "meta_fields:#{metable.class.name}:#{metable.id}"
-
-
Rails.cache.fetch(cache_key, expires_in: 1.hour) do
-
where(metable: metable).pluck(:key, :value, :immutable).to_h do |key, value, immutable|
-
[key, { value: value, immutable: immutable }]
-
end
-
end
-
end
-
-
# Instance methods
-
def to_s
-
value.to_s
-
end
-
-
def to_i
-
value.to_i
-
end
-
-
def to_f
-
value.to_f
-
end
-
-
def to_bool
-
ActiveModel::Type::Boolean.new.cast(value)
-
end
-
-
def json_value
-
JSON.parse(value) if value.present?
-
rescue JSON::ParserError
-
nil
-
end
-
-
private
-
-
def invalidate_metable_cache
-
# Clear individual field cache
-
cache_key = "meta_field:#{metable.class.name}:#{metable.id}:#{key}"
-
Rails.cache.delete(cache_key)
-
-
# Clear metable's meta cache
-
metable_cache_key = "meta_fields:#{metable.class.name}:#{metable.id}"
-
Rails.cache.delete(metable_cache_key)
-
end
-
end
-
class OauthAccount < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Associations
-
belongs_to :user
-
-
# Validations
-
validates :provider, presence: true
-
validates :uid, presence: true
-
validates :email, presence: true
-
validates :name, presence: true
-
validates :uid, uniqueness: { scope: [:provider, :tenant_id] }
-
-
# Scopes
-
scope :by_provider, ->(provider) { where(provider: provider) }
-
scope :active, -> { joins(:user).where(users: { active: true }) }
-
-
# Class methods
-
def self.find_by_provider_and_uid(provider, uid)
-
find_by(provider: provider, uid: uid)
-
end
-
-
def self.find_by_provider_and_email(provider, email)
-
find_by(provider: provider, email: email)
-
end
-
-
# Instance methods
-
def provider_display_name
-
case provider
-
when 'google_oauth2'
-
'Google'
-
when 'github'
-
'GitHub'
-
when 'facebook'
-
'Facebook'
-
when 'twitter'
-
'Twitter'
-
else
-
provider.humanize
-
end
-
end
-
-
def provider_icon
-
case provider
-
when 'google_oauth2'
-
'https://developers.google.com/identity/images/g-logo.png'
-
when 'github'
-
'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png'
-
when 'facebook'
-
'https://facebookbrand.com/wp-content/uploads/2019/04/f_logo_RGB-Hex-Blue_512.png'
-
when 'twitter'
-
'https://abs.twimg.com/icons/apple-touch-icon-192x192.png'
-
else
-
nil
-
end
-
end
-
-
def provider_color
-
case provider
-
when 'google_oauth2'
-
'#4285F4'
-
when 'github'
-
'#333333'
-
when 'facebook'
-
'#1877F2'
-
when 'twitter'
-
'#1DA1F2'
-
else
-
'#6B7280'
-
end
-
end
-
-
def linked_at
-
created_at
-
end
-
-
def last_used_at
-
updated_at
-
end
-
-
# Check if this OAuth account is still valid
-
def valid_oauth_account?
-
user.present? && user.active?
-
end
-
-
# Unlink this OAuth account
-
def unlink!
-
destroy!
-
end
-
-
# Update OAuth account information
-
def update_oauth_info(email: nil, name: nil, avatar_url: nil)
-
update!(
-
email: email || self.email,
-
name: name || self.name,
-
avatar_url: avatar_url || self.avatar_url
-
)
-
end
-
end
-
class Page < ApplicationRecord
-
include Railspress::ChannelDetection
-
# Multi-tenancy
-
acts_as_tenant(:tenant, optional: true)
-
-
# Trash functionality
-
include Trashable
-
-
# Soft deletes
-
include Discard::Model
-
self.discard_column = :deleted_at
-
-
# Versioning
-
has_paper_trail
-
-
# Search - Database agnostic
-
def self.search_full_text(query)
-
return none if query.blank?
-
-
# Simple LIKE search that works with all databases
-
query_pattern = "%#{query}%"
-
where(
-
"title LIKE ? OR meta_description LIKE ? OR content LIKE ?",
-
query_pattern, query_pattern, query_pattern
-
)
-
end
-
-
# Custom Taxonomies
-
include HasTaxonomies
-
-
# Meta fields for plugin extensibility
-
has_many :meta_fields, as: :metable, dependent: :destroy
-
include Metable
-
-
# SEO
-
include SeoOptimizable
-
-
belongs_to :user
-
belongs_to :parent, class_name: 'Page', optional: true
-
belongs_to :page_template, optional: true
-
-
# Rich text content
-
has_rich_text :content
-
-
# Channels
-
has_and_belongs_to_many :channels
-
-
# Hierarchical structure
-
has_many :children, class_name: 'Page', foreign_key: 'parent_id', dependent: :destroy
-
-
# Comments
-
has_many :comments, as: :commentable, dependent: :destroy
-
-
# Status enum
-
enum status: {
-
draft: 0,
-
published: 1,
-
scheduled: 2,
-
pending_review: 3,
-
private_page: 4,
-
trash: 5
-
}, _suffix: true
-
-
# Status scopes
-
scope :visible_to_public, -> {
-
kept.where(status: [:published, :scheduled])
-
.where('published_at IS NULL OR published_at <= ?', Time.current)
-
}
-
scope :not_trashed, -> { where.not(status: :trash) }
-
scope :trashed, -> { where(status: :trash) }
-
scope :awaiting_review, -> { where(status: :pending_review) }
-
scope :scheduled_future, -> {
-
where(status: :scheduled)
-
.where('published_at > ?', Time.current)
-
}
-
scope :scheduled_past, -> {
-
where(status: :scheduled)
-
.where('published_at <= ?', Time.current)
-
}
-
-
# Check if page should be visible to public (without password check)
-
def visible_to_public?
-
return false if trash_status?
-
return false if draft_status?
-
return false if pending_review_status?
-
return false if private_page_status? # Only for logged-in users
-
-
if scheduled_status?
-
published_at.present? && published_at <= Time.current
-
else
-
published_status?
-
end
-
end
-
-
# Check if page is password protected
-
def password_protected?
-
password.present?
-
end
-
-
# Check if provided password is correct
-
def password_matches?(input_password)
-
return true unless password_protected?
-
password == input_password
-
end
-
-
# Auto-publish scheduled pages
-
def check_scheduled_publish
-
if scheduled_status? && published_at.present? && published_at <= Time.current
-
update(status: :published)
-
end
-
end
-
-
# Friendly ID for slugs
-
extend FriendlyId
-
friendly_id :title, use: :slugged
-
-
# Validations
-
validates :title, presence: true
-
validates :slug, presence: true, uniqueness: true
-
validates :status, presence: true
-
validates :password, length: { minimum: 4 }, allow_blank: true
-
-
# Scopes
-
scope :published, -> { where(status: 'published').where('published_at <= ?', Time.current) }
-
scope :root_pages, -> { where(parent_id: nil) }
-
scope :ordered, -> { order(order: :asc, title: :asc) }
-
-
# Callbacks
-
before_validation :set_published_at, if: :published_status?
-
after_create :trigger_page_created_hook
-
after_update :trigger_page_updated_hook, if: :saved_change_to_status?
-
-
# Methods
-
def should_generate_new_friendly_id?
-
title_changed? || slug.blank?
-
end
-
-
def breadcrumbs
-
result = [self]
-
current = self
-
while current.parent.present?
-
result.unshift(current.parent)
-
current = current.parent
-
end
-
result
-
end
-
-
private
-
-
def set_published_at
-
self.published_at ||= Time.current
-
end
-
-
def trigger_page_created_hook
-
Railspress::PluginSystem.do_action('page_created', self)
-
end
-
-
def trigger_page_updated_hook
-
if published_status?
-
Railspress::PluginSystem.do_action('page_published', self)
-
end
-
Railspress::PluginSystem.do_action('page_updated', self)
-
end
-
-
# SEO URL override
-
def seo_default_url
-
Rails.application.routes.url_helpers.page_url(slug)
-
rescue
-
"#"
-
end
-
-
# Custom Fields (ACF-style)
-
has_many :custom_field_values, dependent: :destroy
-
-
# Get field value by name
-
def get_field(field_name)
-
value = custom_field_values.by_key(field_name.to_s).first
-
value&.typed_value
-
end
-
-
# Set field value by name
-
def set_field(field_name, field_value)
-
field = CustomField.joins(:field_group)
-
.where('custom_fields.name = ?', field_name.to_s)
-
.where('field_groups.active = ?', true)
-
.first
-
-
return false unless field
-
-
value_record = custom_field_values.find_or_initialize_by(
-
custom_field: field,
-
meta_key: field_name.to_s
-
)
-
-
value_record.typed_value = field_value
-
value_record.save
-
end
-
-
# Get all fields as hash
-
def get_fields
-
custom_field_values.includes(:custom_field).each_with_object({}) do |cfv, hash|
-
hash[cfv.meta_key] = cfv.typed_value
-
end
-
end
-
-
# Update multiple fields at once
-
def update_fields(fields_hash)
-
fields_hash.each do |key, value|
-
set_field(key, value)
-
end
-
end
-
-
# Get field groups that should be shown for this page
-
def applicable_field_groups
-
FieldGroup.active.ordered.select { |fg| fg.matches_location?(self) }
-
end
-
-
# Template methods
-
def template
-
page_template || default_template
-
end
-
-
def default_template
-
PageTemplate.active.by_type('default').first
-
end
-
-
def render_with_template
-
template&.render_content(self) || content.to_s
-
end
-
end
-
class PageTemplate < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Associations
-
has_many :pages, dependent: :nullify
-
-
# Template types
-
TEMPLATE_TYPES = %w[
-
default
-
full_width
-
landing_page
-
contact_page
-
about_page
-
portfolio_page
-
blog_page
-
custom
-
].freeze
-
-
# Validations
-
validates :name, presence: true
-
validates :template_type, presence: true, inclusion: { in: TEMPLATE_TYPES }
-
validates :template_type, uniqueness: { scope: :tenant_id }
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :by_type, ->(type) { where(template_type: type) }
-
scope :ordered, -> { order(:position, :name) }
-
-
# Callbacks
-
after_initialize :set_defaults, if: :new_record?
-
-
# Methods
-
def render_content(page = nil)
-
content = html_content.presence || default_template
-
-
if page
-
# Replace template variables with page data
-
content = content.gsub('{{page.title}}', page.title || '')
-
content = content.gsub('{{page.content}}', page.content.to_s || '')
-
content = content.gsub('{{page.slug}}', page.slug || '')
-
content = content.gsub('{{page.meta_description}}', page.meta_description || '')
-
end
-
-
content
-
end
-
-
def render_css
-
css_content.presence || default_css
-
end
-
-
def render_js
-
js_content.presence || ''
-
end
-
-
def default_template?
-
template_type == 'default'
-
end
-
-
private
-
-
def set_defaults
-
self.active = true if active.nil?
-
self.position ||= 0
-
self.html_content ||= default_template
-
self.css_content ||= default_css
-
self.js_content ||= ''
-
end
-
-
def default_template
-
case template_type
-
when 'full_width'
-
<<-HTML
-
<div class="min-h-screen">
-
<div class="container mx-auto px-4 py-8">
-
<h1 class="text-4xl font-bold mb-8">{{page.title}}</h1>
-
<div class="prose prose-lg max-w-none">
-
{{page.content}}
-
</div>
-
</div>
-
</div>
-
HTML
-
when 'landing_page'
-
<<-HTML
-
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
-
<div class="container mx-auto px-4 py-16">
-
<div class="text-center">
-
<h1 class="text-5xl font-bold text-gray-900 mb-6">{{page.title}}</h1>
-
<div class="prose prose-lg max-w-3xl mx-auto">
-
{{page.content}}
-
</div>
-
</div>
-
</div>
-
</div>
-
HTML
-
when 'contact_page'
-
<<-HTML
-
<div class="container mx-auto px-4 py-8">
-
<div class="max-w-2xl mx-auto">
-
<h1 class="text-4xl font-bold mb-8">{{page.title}}</h1>
-
<div class="prose prose-lg max-w-none mb-8">
-
{{page.content}}
-
</div>
-
<div class="bg-white rounded-lg shadow-md p-8">
-
<!-- Contact form placeholder -->
-
<form class="space-y-6">
-
<div>
-
<label class="block text-sm font-medium text-gray-700 mb-2">Name</label>
-
<input type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500">
-
</div>
-
<div>
-
<label class="block text-sm font-medium text-gray-700 mb-2">Email</label>
-
<input type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500">
-
</div>
-
<div>
-
<label class="block text-sm font-medium text-gray-700 mb-2">Message</label>
-
<textarea rows="4" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"></textarea>
-
</div>
-
<button type="submit" class="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700">Send Message</button>
-
</form>
-
</div>
-
</div>
-
</div>
-
HTML
-
else
-
<<-HTML
-
<div class="container mx-auto px-4 py-8">
-
<div class="max-w-4xl mx-auto">
-
<article class="bg-white rounded-lg shadow-md p-8">
-
<h1 class="text-4xl font-bold text-gray-900 mb-8">{{page.title}}</h1>
-
<div class="prose prose-lg max-w-none">
-
{{page.content}}
-
</div>
-
</article>
-
</div>
-
</div>
-
HTML
-
end
-
end
-
-
def default_css
-
<<-CSS
-
/* Page Template Styles */
-
.page-template-#{template_type} {
-
font-family: system-ui, -apple-system, sans-serif;
-
line-height: 1.6;
-
color: #333;
-
}
-
-
.page-template-#{template_type} h1,
-
.page-template-#{template_type} h2,
-
.page-template-#{template_type} h3 {
-
color: #1f2937;
-
font-weight: 700;
-
}
-
-
.page-template-#{template_type} .prose {
-
color: #4b5563;
-
}
-
-
.page-template-#{template_type} .prose a {
-
color: #3b82f6;
-
text-decoration: none;
-
}
-
-
.page-template-#{template_type} .prose a:hover {
-
text-decoration: underline;
-
}
-
CSS
-
end
-
end
-
-
-
-
-
-
-
-
class Pageview < ApplicationRecord
-
# Multi-tenancy - make tenant optional for analytics tracking
-
acts_as_tenant(:tenant, optional: true)
-
-
# Associations
-
belongs_to :user, optional: true
-
belongs_to :post, optional: true
-
belongs_to :page, optional: true
-
-
# Serialization
-
serialize :metadata, coder: JSON, type: Hash
-
-
# Validations
-
validates :path, presence: true
-
validates :visited_at, presence: true
-
-
# Scopes
-
scope :consented_only, -> { where(consented: true) }
-
scope :non_bot, -> { where(bot: false) }
-
scope :unique_visitors, -> { where(unique_visitor: true) }
-
scope :returning_visitors, -> { where(returning_visitor: true) }
-
scope :today, -> { where('visited_at >= ?', Time.current.beginning_of_day) }
-
scope :this_week, -> { where('visited_at >= ?', 1.week.ago) }
-
scope :this_month, -> { where('visited_at >= ?', 1.month.ago) }
-
scope :by_country, ->(code) { where(country_code: code) }
-
scope :by_browser, ->(browser) { where(browser: browser) }
-
scope :by_device, ->(device) { where(device: device) }
-
scope :for_post, ->(post_id) { where(post_id: post_id) }
-
scope :for_page, ->(page_id) { where(page_id: page_id) }
-
scope :recent, -> { order(visited_at: :desc) }
-
-
# Class methods for statistics
-
-
# Get overview statistics
-
def self.stats(period: :month)
-
range = case period.to_sym
-
when :today
-
Time.current.beginning_of_day..Time.current.end_of_day
-
when :week
-
1.week.ago..Time.current
-
when :month
-
1.month.ago..Time.current
-
when :year
-
1.year.ago..Time.current
-
else
-
1.month.ago..Time.current
-
end
-
-
views = where(visited_at: range).non_bot
-
consented_views = views.consented_only
-
-
{
-
total_pageviews: views.count,
-
consented_pageviews: consented_views.count,
-
unique_visitors: views.where(unique_visitor: true).count,
-
returning_visitors: views.where(returning_visitor: true).count,
-
avg_duration: views.average(:duration)&.to_i || 0,
-
bounce_rate: calculate_bounce_rate(views),
-
top_pages: top_pages(consented_views, 10),
-
top_posts: top_posts(consented_views, 10),
-
top_countries: top_countries(consented_views, 10),
-
top_browsers: top_browsers(consented_views, 5),
-
top_devices: top_devices(consented_views, 5),
-
top_referrers: top_referrers(consented_views, 10),
-
hourly_distribution: hourly_distribution(consented_views),
-
daily_trend: daily_trend(consented_views, 30)
-
}
-
end
-
-
# Top pages by views
-
def self.top_pages(scope = all, limit = 10)
-
scope.group(:path, :title)
-
.order('count_id DESC')
-
.limit(limit)
-
.count(:id)
-
.map { |k, v| { path: k[0], title: k[1], views: v } }
-
end
-
-
# Top posts by views
-
def self.top_posts(scope = all, limit = 10)
-
scope.where.not(post_id: nil)
-
.group(:post_id)
-
.order('count_id DESC')
-
.limit(limit)
-
.count(:id)
-
.map do |post_id, count|
-
post = Post.find_by(id: post_id)
-
{ post_id: post_id, title: post&.title, views: count }
-
end
-
end
-
-
# Top countries by visitors
-
def self.top_countries(scope = all, limit = 10)
-
scope.where.not(country_code: nil)
-
.group(:country_code)
-
.order('count_id DESC')
-
.limit(limit)
-
.count(:id)
-
.map { |code, count| { country_code: code, count: count } }
-
end
-
-
# Top browsers
-
def self.top_browsers(scope = all, limit = 5)
-
scope.where.not(browser: nil)
-
.group(:browser)
-
.order('count_id DESC')
-
.limit(limit)
-
.count(:id)
-
end
-
-
# Top devices
-
def self.top_devices(scope = all, limit = 5)
-
scope.where.not(device: nil)
-
.group(:device)
-
.order('count_id DESC')
-
.limit(limit)
-
.count(:id)
-
end
-
-
# Top referrers
-
def self.top_referrers(scope = all, limit = 10)
-
scope.where.not(referrer: [nil, ''])
-
.group(:referrer)
-
.order('count_id DESC')
-
.limit(limit)
-
.count(:id)
-
.map { |ref, count| { referrer: ref, count: count } }
-
end
-
-
# Hourly distribution (0-23)
-
def self.hourly_distribution(scope = all)
-
distribution = scope.group("CAST(strftime('%H', visited_at) AS INTEGER)")
-
.count
-
-
(0..23).map { |hour| distribution[hour] || 0 }
-
end
-
-
# Daily trend (last N days)
-
def self.daily_trend(scope = all, days = 30)
-
scope.where('visited_at >= ?', days.days.ago)
-
.group("DATE(visited_at)")
-
.order("DATE(visited_at)")
-
.count
-
.map { |date, count| { date: date, count: count } }
-
end
-
-
# Calculate bounce rate (single-page sessions)
-
def self.calculate_bounce_rate(scope = all)
-
total_sessions = scope.distinct.count(:session_id)
-
return 0 if total_sessions.zero?
-
-
single_page_sessions = scope.group(:session_id)
-
.having('COUNT(*) = 1')
-
.count
-
.size
-
-
((single_page_sessions.to_f / total_sessions) * 100).round(1)
-
end
-
-
# Get real-time active users (last 5 minutes)
-
def self.active_now
-
where('visited_at >= ?', 5.minutes.ago)
-
.non_bot
-
.distinct
-
.count(:session_id)
-
end
-
-
# Track a pageview (called from middleware)
-
def self.track(request, options = {})
-
# Skip if bot and not tracking bots
-
return if is_bot?(request.user_agent) && !options[:track_bots]
-
-
# Parse user agent
-
ua_data = parse_user_agent(request.user_agent)
-
-
# Get or create session
-
session_id = options[:session_id] || generate_session_id(request)
-
-
# Check if unique visitor
-
is_unique = !exists?(session_id: session_id)
-
is_returning = exists?(ip_hash: hash_ip(request.ip)) && !is_unique
-
-
# Get content IDs
-
content_ids = extract_content_ids(request.path)
-
-
# Get geolocation data
-
geolocation_data = get_geolocation_data(request.ip)
-
-
# Resolve tenant for this request
-
tenant = resolve_tenant(request)
-
-
# Enhanced metadata collection
-
enhanced_metadata = (options[:metadata] || {}).merge({
-
request_method: request.request_method,
-
query_string: request.query_string,
-
content_type: request.content_type,
-
accept_language: request.get_header('HTTP_ACCEPT_LANGUAGE'),
-
accept_encoding: request.get_header('HTTP_ACCEPT_ENCODING'),
-
connection: request.get_header('HTTP_CONNECTION'),
-
cache_control: request.get_header('HTTP_CACHE_CONTROL'),
-
timestamp: Time.current.iso8601
-
})
-
-
# Create pageview with enhanced data
-
create!(
-
path: request.path,
-
title: options[:title] || extract_title_from_path(request.path),
-
referrer: request.referer,
-
user_agent: request.user_agent,
-
browser: ua_data[:browser],
-
device: ua_data[:device],
-
os: ua_data[:os],
-
ip_hash: hash_ip(request.ip),
-
session_id: session_id,
-
user_id: options[:user_id],
-
post_id: content_ids[:post_id],
-
page_id: content_ids[:page_id],
-
unique_visitor: is_unique,
-
returning_visitor: is_returning,
-
bot: is_bot?(request.user_agent),
-
consented: options[:consented] || false,
-
visited_at: Time.current,
-
metadata: enhanced_metadata,
-
tenant: tenant,
-
# Geolocation data
-
country_code: geolocation_data&.dig(:country_code),
-
country_name: geolocation_data&.dig(:country_name),
-
city: geolocation_data&.dig(:city),
-
region: geolocation_data&.dig(:region),
-
latitude: geolocation_data&.dig(:latitude),
-
longitude: geolocation_data&.dig(:longitude),
-
timezone: geolocation_data&.dig(:timezone),
-
# Medium-like reader tracking
-
is_reader: options[:is_reader] || false,
-
engagement_score: options[:engagement_score] || 0
-
)
-
-
# Broadcast real-time update via ActionCable
-
RealtimeAnalyticsService.broadcast_new_pageview(pageview) if pageview.persisted?
-
-
pageview
-
rescue => e
-
Rails.logger.error "Failed to track pageview: #{e.message}"
-
nil
-
end
-
-
# GDPR: Anonymize old data
-
def self.anonymize_old_data(days_old = 90)
-
where('created_at < ?', days_old.days.ago).update_all(
-
ip_hash: nil,
-
session_id: nil,
-
city: nil,
-
region: nil,
-
metadata: {}
-
)
-
end
-
-
# GDPR: Delete non-consented data
-
def self.purge_non_consented(days_old = 30)
-
where(consented: false)
-
.where('created_at < ?', days_old.days.ago)
-
.delete_all
-
end
-
-
private
-
-
# Get geolocation data for an IP address
-
def self.get_geolocation_data(ip_address)
-
return nil unless SiteSetting.get('geolocation_enabled', true)
-
-
begin
-
GeolocationService.instance.lookup_ip(ip_address)
-
rescue => e
-
Rails.logger.error "Geolocation lookup failed for #{ip_address}: #{e.message}"
-
nil
-
end
-
end
-
-
# High-volume performance optimizations
-
def self.high_volume_mode?
-
SiteSetting.get('analytics_high_volume_mode', false)
-
end
-
-
def self.track_async(request, options)
-
# Queue pageview for background processing
-
AnalyticsProcessingJob.perform_later(
-
path: request.path,
-
referrer: request.referer,
-
user_agent: request.user_agent,
-
ip_hash: hash_ip(request.ip),
-
session_id: options[:session_id] || generate_session_id,
-
tenant_id: options[:tenant_id],
-
visited_at: Time.current,
-
bot: is_bot?(request.user_agent),
-
consented: options[:consented] || false
-
)
-
end
-
-
def self.parse_user_agent_cached(user_agent)
-
# Simple caching for user agent parsing
-
@ua_cache ||= {}
-
@ua_cache[user_agent] ||= parse_user_agent(user_agent)
-
end
-
-
# Resolve tenant for the given request
-
def self.resolve_tenant(request)
-
# Priority 1: Find tenant by domain or subdomain
-
if request.host != 'localhost'
-
tenant = Tenant.find_by(domain: request.host) ||
-
Tenant.find_by(subdomain: request.subdomains.first)
-
return tenant if tenant
-
end
-
-
# Priority 2: Use default tenant for localhost/frontend
-
unless request.path.start_with?('/admin')
-
tenant = Tenant.first || Tenant.create!(
-
name: 'RailsPress Default',
-
domain: 'localhost',
-
theme: 'nordic',
-
storage_type: 'local'
-
)
-
return tenant
-
end
-
-
# Priority 3: Try to get from current acts_as_tenant context
-
ActsAsTenant.current_tenant
-
end
-
-
# Hash IP address for privacy
-
def self.hash_ip(ip)
-
return nil unless ip
-
Digest::SHA256.hexdigest("#{ip}-#{Rails.application.secret_key_base}")[0..15]
-
end
-
-
# Generate session ID
-
def self.generate_session_id(request)
-
data = "#{request.ip}-#{request.user_agent}-#{Date.today}"
-
Digest::SHA256.hexdigest(data)[0..31]
-
end
-
-
# Check if bot
-
def self.is_bot?(user_agent)
-
return true if user_agent.blank?
-
-
bot_patterns = [
-
/bot/i, /crawl/i, /spider/i, /slurp/i,
-
/googlebot/i, /bingbot/i, /yandex/i,
-
/facebookexternalhit/i, /twitterbot/i,
-
/whatsapp/i, /telegram/i
-
]
-
-
bot_patterns.any? { |pattern| user_agent.match?(pattern) }
-
end
-
-
# Parse user agent
-
def self.parse_user_agent(ua)
-
return { browser: 'Unknown', device: 'Unknown', os: 'Unknown' } if ua.blank?
-
-
# Simple parsing (you can use a gem like browser for more accurate parsing)
-
browser = case ua
-
when /Chrome/i then 'Chrome'
-
when /Firefox/i then 'Firefox'
-
when /Safari/i then 'Safari'
-
when /Edge/i then 'Edge'
-
when /Opera/i then 'Opera'
-
else 'Other'
-
end
-
-
device = case ua
-
when /Mobile|Android|iPhone|iPad/i then 'Mobile'
-
when /Tablet/i then 'Tablet'
-
else 'Desktop'
-
end
-
-
os = case ua
-
when /Windows/i then 'Windows'
-
when /Mac OS X/i then 'macOS'
-
when /Linux/i then 'Linux'
-
when /Android/i then 'Android'
-
when /iOS|iPhone|iPad/i then 'iOS'
-
else 'Other'
-
end
-
-
{ browser: browser, device: device, os: os }
-
end
-
-
# Extract post/page IDs from path
-
def self.extract_content_ids(path)
-
ids = { post_id: nil, page_id: nil }
-
-
# Try to match blog post pattern
-
if path.match?(/\/blog\/(.+)/)
-
slug = path.split('/').last
-
post = Post.find_by(slug: slug)
-
ids[:post_id] = post&.id
-
end
-
-
# Try to match page pattern
-
unless ids[:post_id]
-
slug = path.split('/').reject(&:blank?).last
-
page_obj = Page.find_by(slug: slug) if slug
-
ids[:page_id] = page_obj&.id
-
end
-
-
ids
-
end
-
-
# Extract title from path for better tracking
-
def self.extract_title_from_path(path)
-
case path
-
when '/'
-
'Home Page'
-
when '/blog'
-
'Blog Index'
-
when /\/blog\/(.+)/
-
slug = $1
-
post = Post.find_by(slug: slug)
-
post&.title || "Blog Post: #{slug.humanize}"
-
when /\/page\/(.+)/, /^\/(.+)$/
-
slug = $1
-
page = Page.find_by(slug: slug)
-
page&.title || "#{slug.humanize} Page"
-
else
-
"#{path.split('/').last&.humanize || 'Page'}"
-
end
-
end
-
end
-
class PersonalDataErasureRequest < ApplicationRecord
-
acts_as_tenant(:tenant)
-
-
belongs_to :user
-
-
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
-
validates :token, presence: true, uniqueness: true
-
validates :status, presence: true
-
-
enum status: {
-
pending_confirmation: 'pending_confirmation',
-
processing: 'processing',
-
completed: 'completed',
-
failed: 'failed',
-
cancelled: 'cancelled'
-
}, _suffix: true
-
-
scope :recent, -> { order(created_at: :desc) }
-
scope :awaiting_confirmation, -> { where(status: 'pending_confirmation') }
-
-
after_create :generate_token
-
-
private
-
-
def generate_token
-
self.token ||= SecureRandom.hex(32)
-
end
-
end
-
class PersonalDataExportRequest < ApplicationRecord
-
acts_as_tenant(:tenant)
-
-
belongs_to :user
-
-
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
-
validates :token, presence: true, uniqueness: true
-
validates :status, presence: true
-
-
enum status: {
-
pending: 'pending',
-
processing: 'processing',
-
completed: 'completed',
-
failed: 'failed'
-
}, _suffix: true
-
-
scope :recent, -> { order(created_at: :desc) }
-
scope :pending_expiry, -> { where('completed_at < ?', 7.days.ago) }
-
-
after_create :generate_token
-
-
private
-
-
def generate_token
-
self.token ||= SecureRandom.hex(32)
-
end
-
end
-
class Pixel < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Versioning
-
has_paper_trail
-
-
# Enums
-
enum pixel_type: {
-
google_analytics: 0,
-
google_tag_manager: 1,
-
facebook_pixel: 2,
-
tiktok_pixel: 3,
-
linkedin_insight: 4,
-
twitter_pixel: 5,
-
pinterest_tag: 6,
-
snapchat_pixel: 7,
-
reddit_pixel: 8,
-
hotjar: 9,
-
clarity: 10,
-
mixpanel: 11,
-
segment: 12,
-
heap: 13,
-
custom: 99
-
}
-
-
enum position: {
-
head: 0, # <head> section
-
body_start: 1, # After <body>
-
body_end: 2 # Before </body>
-
}
-
-
# Validations
-
validates :name, presence: true
-
validates :pixel_type, presence: true
-
validates :position, presence: true
-
validate :requires_pixel_id_or_custom_code
-
validate :custom_code_is_safe
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :inactive, -> { where(active: false) }
-
scope :by_position, ->(pos) { where(position: pos) }
-
scope :by_provider, ->(provider) { where(provider: provider) }
-
scope :ordered, -> { order(position: :asc, created_at: :asc) }
-
-
# Instance methods
-
-
# Render the pixel code
-
def render_code
-
return '' unless active?
-
-
if custom?
-
sanitize_custom_code(custom_code || '')
-
else
-
generate_provider_code
-
end
-
end
-
-
# Check if pixel is properly configured
-
def configured?
-
if custom?
-
custom_code.present?
-
else
-
pixel_id.present?
-
end
-
end
-
-
private
-
-
def requires_pixel_id_or_custom_code
-
if custom? && custom_code.blank?
-
errors.add(:custom_code, "can't be blank for custom pixels")
-
elsif !custom? && pixel_id.blank?
-
errors.add(:pixel_id, "can't be blank for #{pixel_type} pixels")
-
end
-
end
-
-
def custom_code_is_safe
-
return unless custom_code.present?
-
-
# Basic security checks
-
dangerous_patterns = [
-
/<script[^>]*src=/i, # External scripts
-
/eval\(/i, # eval() calls
-
/document\.write/i, # document.write
-
/on\w+=/i # Inline event handlers
-
]
-
-
dangerous_patterns.each do |pattern|
-
if custom_code.match?(pattern)
-
errors.add(:custom_code, "contains potentially dangerous code pattern")
-
break
-
end
-
end
-
end
-
-
def sanitize_custom_code(code)
-
# Return code as-is but wrapped in comment for admin reference
-
# In production, you might want more strict sanitization
-
code
-
end
-
-
def generate_provider_code
-
case pixel_type.to_sym
-
when :google_analytics
-
google_analytics_code
-
when :google_tag_manager
-
google_tag_manager_code
-
when :facebook_pixel
-
facebook_pixel_code
-
when :tiktok_pixel
-
tiktok_pixel_code
-
when :linkedin_insight
-
linkedin_insight_code
-
when :twitter_pixel
-
twitter_pixel_code
-
when :pinterest_tag
-
pinterest_tag_code
-
when :snapchat_pixel
-
snapchat_pixel_code
-
when :reddit_pixel
-
reddit_pixel_code
-
when :hotjar
-
hotjar_code
-
when :clarity
-
clarity_code
-
when :mixpanel
-
mixpanel_code
-
when :segment
-
segment_code
-
when :heap
-
heap_code
-
else
-
''
-
end
-
end
-
-
# Provider-specific code generators
-
-
def google_analytics_code
-
<<~HTML
-
<!-- Google Analytics -->
-
<script async src="https://www.googletagmanager.com/gtag/js?id=#{pixel_id}"></script>
-
<script>
-
window.dataLayer = window.dataLayer || [];
-
function gtag(){dataLayer.push(arguments);}
-
gtag('js', new Date());
-
gtag('config', '#{pixel_id}');
-
</script>
-
HTML
-
end
-
-
def google_tag_manager_code
-
if position.to_sym == :head || position.to_sym == :body_start
-
<<~HTML
-
<!-- Google Tag Manager -->
-
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
-
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
-
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
-
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
-
})(window,document,'script','dataLayer','#{pixel_id}');</script>
-
<!-- End Google Tag Manager -->
-
HTML
-
else
-
<<~HTML
-
<!-- Google Tag Manager (noscript) -->
-
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=#{pixel_id}"
-
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
-
<!-- End Google Tag Manager (noscript) -->
-
HTML
-
end
-
end
-
-
def facebook_pixel_code
-
<<~HTML
-
<!-- Meta Pixel Code -->
-
<script>
-
!function(f,b,e,v,n,t,s)
-
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
-
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
-
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
-
n.queue=[];t=b.createElement(e);t.async=!0;
-
t.src=v;s=b.getElementsByTagName(e)[0];
-
s.parentNode.insertBefore(t,s)}(window, document,'script',
-
'https://connect.facebook.net/en_US/fbevents.js');
-
fbq('init', '#{pixel_id}');
-
fbq('track', 'PageView');
-
</script>
-
<noscript><img height="1" width="1" style="display:none"
-
src="https://www.facebook.com/tr?id=#{pixel_id}&ev=PageView&noscript=1"
-
/></noscript>
-
<!-- End Meta Pixel Code -->
-
HTML
-
end
-
-
def tiktok_pixel_code
-
<<~HTML
-
<!-- TikTok Pixel Code -->
-
<script>
-
!function (w, d, t) {
-
w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};var o=document.createElement("script");o.type="text/javascript",o.async=!0,o.src=i+"?sdkid="+e+"&lib="+t;var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};
-
ttq.load('#{pixel_id}');
-
ttq.page();
-
}(window, document, 'ttq');
-
</script>
-
<!-- End TikTok Pixel Code -->
-
HTML
-
end
-
-
def linkedin_insight_code
-
<<~HTML
-
<!-- LinkedIn Insight Tag -->
-
<script type="text/javascript">
-
_linkedin_partner_id = "#{pixel_id}";
-
window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
-
window._linkedin_data_partner_ids.push(_linkedin_partner_id);
-
</script><script type="text/javascript">
-
(function(l) {
-
if (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};
-
window.lintrk.q=[]}
-
var s = document.getElementsByTagName("script")[0];
-
var b = document.createElement("script");
-
b.type = "text/javascript";b.async = true;
-
b.src = "https://snap.licdn.com/li.lms-analytics/insight.min.js";
-
s.parentNode.insertBefore(b, s);})(window.lintrk);
-
</script>
-
<noscript>
-
<img height="1" width="1" style="display:none;" alt="" src="https://px.ads.linkedin.com/collect/?pid=#{pixel_id}&fmt=gif" />
-
</noscript>
-
<!-- End LinkedIn Insight Tag -->
-
HTML
-
end
-
-
def twitter_pixel_code
-
<<~HTML
-
<!-- Twitter Pixel Code -->
-
<script>
-
!function(e,t,n,s,u,a){e.twq||(s=e.twq=function(){s.exe?s.exe.apply(s,arguments):s.queue.push(arguments);
-
},s.version='1.1',s.queue=[],u=t.createElement(n),u.async=!0,u.src='https://static.ads-twitter.com/uwt.js',
-
a=t.getElementsByTagName(n)[0],a.parentNode.insertBefore(u,a))}(window,document,'script');
-
twq('config','#{pixel_id}');
-
</script>
-
<!-- End Twitter Pixel Code -->
-
HTML
-
end
-
-
def pinterest_tag_code
-
<<~HTML
-
<!-- Pinterest Tag -->
-
<script>
-
!function(e){if(!window.pintrk){window.pintrk = function () {
-
window.pintrk.queue.push(Array.prototype.slice.call(arguments))};var
-
n=window.pintrk;n.queue=[],n.version="3.0";var
-
t=document.createElement("script");t.async=!0,t.src=e;var
-
r=document.getElementsByTagName("script")[0];
-
r.parentNode.insertBefore(t,r)}}("https://s.pinimg.com/ct/core.js");
-
pintrk('load', '#{pixel_id}', {em: '<user_email_address>'});
-
pintrk('page');
-
</script>
-
<noscript>
-
<img height="1" width="1" style="display:none;" alt=""
-
src="https://ct.pinterest.com/v3/?event=init&tid=#{pixel_id}&noscript=1" />
-
</noscript>
-
<!-- End Pinterest Tag -->
-
HTML
-
end
-
-
def snapchat_pixel_code
-
<<~HTML
-
<!-- Snapchat Pixel Code -->
-
<script type='text/javascript'>
-
(function(e,t,n){if(e.snaptr)return;var a=e.snaptr=function()
-
{a.handleRequest?a.handleRequest.apply(a,arguments):a.queue.push(arguments)};
-
a.queue=[];var s='script';r=t.createElement(s);r.async=!0;
-
r.src=n;var u=t.getElementsByTagName(s)[0];
-
u.parentNode.insertBefore(r,u);})(window,document,
-
'https://sc-static.net/scevent.min.js');
-
snaptr('init', '#{pixel_id}', {
-
'user_email': '__INSERT_USER_EMAIL__'
-
});
-
snaptr('track', 'PAGE_VIEW');
-
</script>
-
<!-- End Snapchat Pixel Code -->
-
HTML
-
end
-
-
def reddit_pixel_code
-
<<~HTML
-
<!-- Reddit Pixel -->
-
<script>
-
!function(w,d){if(!w.rdt){var p=w.rdt=function(){p.sendEvent?p.sendEvent.apply(p,arguments):p.callQueue.push(arguments)};p.callQueue=[];var t=d.createElement("script");t.src="https://www.redditstatic.com/ads/pixel.js",t.async=!0;var s=d.getElementsByTagName("script")[0];s.parentNode.insertBefore(t,s)}}(window,document);
-
rdt('init','#{pixel_id}');
-
rdt('track', 'PageVisit');
-
</script>
-
<!-- End Reddit Pixel -->
-
HTML
-
end
-
-
def hotjar_code
-
<<~HTML
-
<!-- Hotjar Tracking Code -->
-
<script>
-
(function(h,o,t,j,a,r){
-
h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)};
-
h._hjSettings={hjid:#{pixel_id},hjsv:6};
-
a=o.getElementsByTagName('head')[0];
-
r=o.createElement('script');r.async=1;
-
r.src=t+h._hjSettings.hjid+j+h._hjSettings.hjsv;
-
a.appendChild(r);
-
})(window,document,'https://static.hotjar.com/c/hotjar-','.js?sv=');
-
</script>
-
<!-- End Hotjar Tracking Code -->
-
HTML
-
end
-
-
def clarity_code
-
<<~HTML
-
<!-- Microsoft Clarity -->
-
<script type="text/javascript">
-
(function(c,l,a,r,i,t,y){
-
c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};
-
t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/"+i;
-
y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);
-
})(window, document, "clarity", "script", "#{pixel_id}");
-
</script>
-
<!-- End Microsoft Clarity -->
-
HTML
-
end
-
-
def mixpanel_code
-
<<~HTML
-
<!-- Mixpanel -->
-
<script type="text/javascript">
-
(function(f,b){if(!b.__SV){var e,g,i,h;window.mixpanel=b;b._i=[];b.init=function(e,f,c){function g(a,d){var b=d.split(".");2==b.length&&(a=a[b[0]],d=b[1]);a[d]=function(){a.push([d].concat(Array.prototype.slice.call(arguments,0)))}}var a=b;"undefined"!==typeof c?a=b[c]=[]:c="mixpanel";a.people=a.people||[];a.toString=function(a){var d="mixpanel";"mixpanel"!==c&&(d+="."+c);a||(d+=" (stub)");return d};a.people.toString=function(){return a.toString(1)+".people (stub)"};i="disable time_event track track_pageview track_links track_forms track_with_groups add_group set_group remove_group register register_once alias unregister identify name_tag set_config reset opt_in_tracking opt_out_tracking has_opted_in_tracking has_opted_out_tracking clear_opt_in_out_tracking start_batch_senders people.set people.set_once people.unset people.increment people.append people.union people.track_charge people.clear_charges people.delete_user people.remove".split(" ");
-
for(h=0;h<i.length;h++)g(a,i[h]);var j="set set_once union unset remove delete".split(" ");a.get_group=function(){function b(c){d[c]=function(){call2_args=arguments;call2=[c].concat(Array.prototype.slice.call(call2_args,0));a.push([e,call2])}}for(var d={},e=["get_group"].concat(Array.prototype.slice.call(arguments,0)),c=0;c<j.length;c++)b(j[c]);return d};b._i.push([e,f,c])};b.__SV=1.2;e=f.createElement("script");e.type="text/javascript";e.async=!0;e.src="undefined"!==typeof MIXPANEL_CUSTOM_LIB_URL?
-
MIXPANEL_CUSTOM_LIB_URL:"file:"===f.location.protocol&&"//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js".match(/^\\/\\//)?"https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js":"//cdn.mxpnl.com/libs/mixpanel-2-latest.min.js";g=f.getElementsByTagName("script")[0];g.parentNode.insertBefore(e,g)}})(document,window.mixpanel||[]);
-
mixpanel.init("#{pixel_id}");
-
</script>
-
<!-- End Mixpanel -->
-
HTML
-
end
-
-
def segment_code
-
<<~HTML
-
<!-- Segment -->
-
<script type="text/javascript">
-
!function(){var analytics=window.analytics=window.analytics||[];if(!analytics.initialize)if(analytics.invoked)window.console&&console.error&&console.error("Segment snippet included twice.");else{analytics.invoked=!0;analytics.methods=["trackSubmit","trackClick","trackLink","trackForm","pageview","identify","reset","group","track","ready","alias","debug","page","once","off","on","addSourceMiddleware","addIntegrationMiddleware","setAnonymousId","addDestinationMiddleware"];analytics.factory=function(e){return function(){var t=Array.prototype.slice.call(arguments);t.unshift(e);analytics.push(t);return analytics}};for(var e=0;e<analytics.methods.length;e++){var key=analytics.methods[e];analytics[key]=analytics.factory(key)}analytics.load=function(key,e){var t=document.createElement("script");t.type="text/javascript";t.async=!0;t.src="https://cdn.segment.com/analytics.js/v1/" + key + "/analytics.min.js";var n=document.getElementsByTagName("script")[0];n.parentNode.insertBefore(t,n);analytics._loadOptions=e};analytics._writeKey="#{pixel_id}";;analytics.SNIPPET_VERSION="4.15.3";
-
analytics.load("#{pixel_id}");
-
analytics.page();
-
}}();
-
</script>
-
<!-- End Segment -->
-
HTML
-
end
-
-
def heap_code
-
<<~HTML
-
<!-- Heap Analytics -->
-
<script type="text/javascript">
-
window.heap=window.heap||[],heap.load=function(e,t){window.heap.appid=e,window.heap.config=t=t||{};var r=document.createElement("script");r.type="text/javascript",r.async=!0,r.src="https://cdn.heapanalytics.com/js/heap-"+e+".js";var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(r,a);for(var n=function(e){return function(){heap.push([e].concat(Array.prototype.slice.call(arguments,0)))}},p=["addEventProperties","addUserProperties","clearEventProperties","identify","resetIdentity","removeEventProperty","setEventProperties","track","unsetEventProperty"],o=0;o<p.length;o++)heap[p[o]]=n(p[o])};
-
heap.load("#{pixel_id}");
-
</script>
-
<!-- End Heap Analytics -->
-
HTML
-
end
-
end
-
1
class Plugin < ApplicationRecord
-
# Serialization
-
1
serialize :settings, coder: JSON, type: Hash
-
-
# Validations
-
1
validates :name, presence: true, uniqueness: true
-
1
validates :version, presence: true
-
-
# Scopes
-
2
scope :active, -> { where(active: true) }
-
-
# Callbacks
-
1
after_initialize :set_defaults, if: :new_record?
-
-
# Methods
-
1
def activate!
-
update(active: true)
-
end
-
-
1
def deactivate!
-
update(active: false)
-
end
-
-
1
private
-
-
1
def set_defaults
-
then: 0
else: 0
self.active = false if active.nil?
-
self.settings ||= {}
-
end
-
end
-
class PluginSetting < ApplicationRecord
-
# Validations
-
validates :plugin_name, presence: true
-
validates :key, presence: true, uniqueness: { scope: :plugin_name }
-
validates :setting_type, inclusion: { in: %w[string boolean integer float array json text] }, allow_nil: true
-
-
# Scopes
-
scope :for_plugin, ->(plugin_name) { where(plugin_name: plugin_name) }
-
scope :by_key, ->(key) { where(key: key) }
-
-
# Callbacks
-
before_save :set_default_type
-
-
# Get typed value
-
def typed_value
-
case setting_type
-
when 'boolean'
-
value == 'true' || value == '1' || value == true
-
when 'integer'
-
value.to_i
-
when 'float'
-
value.to_f
-
when 'array', 'json'
-
JSON.parse(value) rescue []
-
else
-
value
-
end
-
end
-
-
# Set typed value
-
def typed_value=(new_value)
-
case setting_type
-
when 'boolean'
-
self.value = new_value.to_s
-
when 'integer', 'float'
-
self.value = new_value.to_s
-
when 'array', 'json'
-
self.value = new_value.to_json
-
else
-
self.value = new_value.to_s
-
end
-
end
-
-
# Class method to get setting
-
def self.get(plugin_name, key, default = nil)
-
setting = find_by(plugin_name: plugin_name, key: key)
-
setting ? setting.typed_value : default
-
end
-
-
# Class method to set setting
-
def self.set(plugin_name, key, value, type = 'string')
-
setting = find_or_initialize_by(plugin_name: plugin_name, key: key)
-
setting.setting_type = type
-
setting.typed_value = value
-
setting.save!
-
setting
-
end
-
-
private
-
-
def set_default_type
-
self.setting_type ||= 'string'
-
end
-
end
-
class Post < ApplicationRecord
-
include Railspress::ChannelDetection
-
# Multi-tenancy
-
# acts_as_tenant(:tenant, optional: true) # Temporarily disabled for testing
-
-
# Trash functionality
-
include Trashable
-
-
# Soft deletes
-
include Discard::Model
-
self.discard_column = :deleted_at
-
-
# Versioning
-
has_paper_trail
-
-
# Versioning methods
-
def versions_count
-
versions.count
-
end
-
-
def latest_version
-
versions.last
-
end
-
-
def version_at(timestamp)
-
versions.where('created_at <= ?', timestamp).order(:created_at).last
-
end
-
-
def changes_since(version)
-
return {} unless version
-
-
current_changes = {}
-
version.changeset.each do |field, change|
-
current_changes[field] = {
-
from: change[0],
-
to: change[1],
-
current: send(field)
-
}
-
end
-
current_changes
-
end
-
-
def restore_to_version(version_id)
-
version = versions.find(version_id)
-
return false unless version
-
-
# Create a backup of current version before restoring
-
PaperTrail.without_versioning do
-
version.reify.save!
-
end
-
true
-
rescue => e
-
Rails.logger.error "Failed to restore version #{version_id}: #{e.message}"
-
false
-
end
-
-
def version_summary(version)
-
changes = version.changeset
-
return "Initial version" if changes.empty?
-
-
summary_parts = []
-
summary_parts << "Title changed" if changes.key?('title')
-
summary_parts << "Content updated" if changes.key?('content')
-
summary_parts << "Status changed" if changes.key?('status')
-
summary_parts << "SEO updated" if changes.key?('meta_title') || changes.key?('meta_description')
-
-
summary_parts.any? ? summary_parts.join(', ') : "Minor changes"
-
end
-
-
# Search - Database agnostic
-
def self.search_full_text(query)
-
return none if query.blank?
-
-
# Simple LIKE search that works with all databases
-
query_pattern = "%#{query}%"
-
where(
-
"title LIKE ? OR excerpt LIKE ? OR meta_description LIKE ? OR content LIKE ?",
-
query_pattern, query_pattern, query_pattern, query_pattern
-
)
-
end
-
-
# Custom Taxonomies
-
include HasTaxonomies
-
-
# Meta fields for plugin extensibility
-
has_many :meta_fields, as: :metable, dependent: :destroy
-
include Metable
-
-
# Set up taxonomy associations
-
has_taxonomy :category
-
has_taxonomy :post_tag
-
-
# SEO
-
include SeoOptimizable
-
-
belongs_to :user
-
belongs_to :content_type, optional: true
-
-
# Alias for semantic clarity
-
def author
-
user
-
end
-
-
# Get content type or default to 'post'
-
def post_type
-
content_type || ContentType.default_type
-
end
-
-
def post_type_ident
-
post_type&.ident || 'post'
-
end
-
-
def author=(value)
-
self.user = value
-
end
-
-
# Rich text content
-
has_rich_text :content
-
-
# Media/image support
-
has_one_attached :featured_image_file
-
-
# Channels
-
has_and_belongs_to_many :channels
-
-
# Associations
-
has_many :comments, as: :commentable, dependent: :destroy
-
-
# Status enum (like WordPress)
-
enum status: {
-
draft: 0,
-
published: 1,
-
scheduled: 2,
-
pending_review: 3,
-
private_post: 4,
-
trash: 5
-
}, _suffix: true
-
-
# Status scopes
-
scope :visible_to_public, -> {
-
kept.where(status: [:published, :scheduled])
-
.where('published_at IS NULL OR published_at <= ?', Time.current)
-
}
-
scope :not_trashed, -> { where.not(status: :trash) }
-
scope :trashed, -> { where(status: :trash) }
-
scope :awaiting_review, -> { where(status: :pending_review) }
-
scope :scheduled_future, -> {
-
where(status: :scheduled)
-
.where('published_at > ?', Time.current)
-
}
-
scope :scheduled_past, -> {
-
where(status: :scheduled)
-
.where('published_at <= ?', Time.current)
-
}
-
-
# Check if post should be visible to public (without password check)
-
def visible_to_public?
-
return false if trash_status?
-
return false if draft_status?
-
return false if pending_review_status?
-
return false if private_post_status? # Only for logged-in users
-
-
if scheduled_status?
-
published_at.present? && published_at <= Time.current
-
else
-
published_status?
-
end
-
end
-
-
# Check if post is password protected
-
def password_protected?
-
password.present?
-
end
-
-
# Check if provided password is correct
-
def password_matches?(input_password)
-
return true unless password_protected?
-
password == input_password
-
end
-
-
# Auto-publish scheduled posts
-
def check_scheduled_publish
-
if scheduled_status? && published_at.present? && published_at <= Time.current
-
update(status: :published)
-
end
-
end
-
-
# Friendly ID for slugs
-
extend FriendlyId
-
friendly_id :title, use: :slugged
-
-
# Validations
-
validates :title, presence: true
-
validates :slug, presence: true, uniqueness: { scope: :tenant_id }
-
validates :status, presence: true
-
validates :password, length: { minimum: 4 }, allow_blank: true
-
-
# Scopes
-
scope :published, -> { where(status: 'published').where('published_at <= ?', Time.current) }
-
scope :scheduled, -> { where(status: 'scheduled').where('published_at > ?', Time.current) }
-
scope :recent, -> { order(published_at: :desc) }
-
scope :by_category, ->(category) { joins(:terms).where(terms: { slug: category }).joins('INNER JOIN taxonomies ON terms.taxonomy_id = taxonomies.id').where(taxonomies: { slug: 'category' }) }
-
scope :by_tag, ->(tag) { joins(:terms).where(terms: { slug: tag }).joins('INNER JOIN taxonomies ON terms.taxonomy_id = taxonomies.id').where(taxonomies: { slug: 'post_tag' }) }
-
scope :search, ->(query) { where("title ILIKE ? OR content ILIKE ?", "%#{query}%", "%#{query}%") }
-
-
# Callbacks
-
before_validation :set_published_at, if: :published_status?
-
after_create :trigger_post_created_hook
-
after_update :trigger_post_updated_hook, if: :saved_change_to_status?
-
-
# Methods
-
def should_generate_new_friendly_id?
-
title_changed? || slug.blank?
-
end
-
-
def author_name
-
user&.name || user&.email&.split('@')&.first&.titleize || 'Anonymous'
-
end
-
-
private
-
-
def set_published_at
-
self.published_at ||= Time.current
-
end
-
-
def trigger_post_created_hook
-
Railspress::PluginSystem.do_action('post_created', self)
-
Railspress::WebhookDispatcher.dispatch('post.created', self)
-
end
-
-
def trigger_post_updated_hook
-
if published_status?
-
Railspress::PluginSystem.do_action('post_published', self)
-
Railspress::WebhookDispatcher.dispatch('post.published', self)
-
end
-
Railspress::PluginSystem.do_action('post_updated', self)
-
Railspress::WebhookDispatcher.dispatch('post.updated', self)
-
end
-
-
# SEO URL override
-
def seo_default_url
-
Rails.application.routes.url_helpers.blog_post_url(slug)
-
rescue
-
"#"
-
end
-
-
# Featured image URL for SEO
-
def featured_image_url
-
return nil unless featured_image_file.attached?
-
Rails.application.routes.url_helpers.url_for(featured_image_file)
-
rescue
-
nil
-
end
-
-
# Custom Fields (ACF-style)
-
has_many :custom_field_values, dependent: :destroy
-
-
# Get field value by name
-
def get_field(field_name)
-
value = custom_field_values.by_key(field_name.to_s).first
-
value&.typed_value
-
end
-
-
# Set field value by name
-
def set_field(field_name, field_value)
-
field = CustomField.joins(:field_group)
-
.where('custom_fields.name = ?', field_name.to_s)
-
.where('field_groups.active = ?', true)
-
.first
-
-
return false unless field
-
-
value_record = custom_field_values.find_or_initialize_by(
-
custom_field: field,
-
meta_key: field_name.to_s
-
)
-
-
value_record.typed_value = field_value
-
value_record.save
-
end
-
-
# Get all fields as hash
-
def get_fields
-
custom_field_values.includes(:custom_field).each_with_object({}) do |cfv, hash|
-
hash[cfv.meta_key] = cfv.typed_value
-
end
-
end
-
-
# Update multiple fields at once
-
def update_fields(fields_hash)
-
fields_hash.each do |key, value|
-
set_field(key, value)
-
end
-
end
-
-
# Get field groups that should be shown for this post
-
def applicable_field_groups
-
FieldGroup.active.ordered.select { |fg| fg.matches_location?(self) }
-
end
-
-
# Generate URL for the post
-
def url
-
Rails.application.routes.url_helpers.blog_post_url(self.id, host: 'localhost:3000')
-
end
-
-
# Get the author of the post
-
def author
-
User.find_by(id: self.user_id)
-
end
-
-
# Get categories for the post
-
def categories
-
# This would need to be implemented based on your taxonomy system
-
# For now, return an empty array to prevent errors
-
[]
-
end
-
-
# Convert Post to Liquid-compatible hash
-
def to_liquid
-
{
-
'id' => id,
-
'title' => title,
-
'content' => content.to_s, # Convert ActionText to string
-
'excerpt' => excerpt,
-
'url' => url,
-
'author' => author,
-
'categories' => categories.to_a, # Convert AssociationRelation to array
-
'terms' => terms.to_a, # Convert AssociationRelation to array
-
'published_at' => published_at,
-
'created_at' => created_at,
-
'updated_at' => updated_at,
-
'featured_image' => featured_image
-
}
-
end
-
-
# Make these methods public for Liquid access
-
public :url, :author, :categories, :to_liquid
-
end
-
class PublishedThemeFile < ApplicationRecord
-
belongs_to :published_theme_version
-
-
# Scopes
-
scope :templates, -> { where(file_type: 'template') }
-
scope :sections, -> { where(file_type: 'section') }
-
scope :layouts, -> { where(file_type: 'layout') }
-
scope :assets, -> { where(file_type: 'asset') }
-
scope :configs, -> { where(file_type: 'config') }
-
end
-
class PublishedThemeVersion < ApplicationRecord
-
belongs_to :published_by, polymorphic: true
-
belongs_to :tenant
-
belongs_to :theme
-
has_many :published_theme_files, dependent: :destroy
-
-
# Scopes
-
scope :for_theme, ->(theme_or_name) {
-
if theme_or_name.is_a?(Theme)
-
where(theme: theme_or_name)
-
else
-
joins(:theme).where("LOWER(themes.name) = ?", theme_or_name.to_s.downcase)
-
end
-
}
-
scope :latest, -> { order(version_number: :desc) }
-
-
# Get file content
-
def file_content(file_path)
-
file = published_theme_files.find_by(file_path: file_path)
-
file&.content
-
end
-
-
# Get parsed JSON file
-
def parsed_file(file_path)
-
content = file_content(file_path)
-
return nil unless content
-
-
JSON.parse(content)
-
rescue JSON::ParserError
-
nil
-
end
-
-
# Liquid file system compatibility methods
-
def read_template_file(template_path)
-
Rails.logger.info "PublishedVersion: Looking for template: #{template_path}"
-
-
# Try to find the file directly
-
file = published_theme_files.find_by(file_path: template_path)
-
if file
-
Rails.logger.info "PublishedVersion: Found template file: #{template_path}"
-
return file.content
-
end
-
-
# Try with .liquid extension
-
file = published_theme_files.find_by(file_path: "#{template_path}.liquid")
-
if file
-
Rails.logger.info "PublishedVersion: Found template file with .liquid: #{template_path}.liquid"
-
return file.content
-
end
-
-
# Try snippets directory
-
file = published_theme_files.find_by(file_path: "snippets/#{template_path}.liquid")
-
if file
-
Rails.logger.info "PublishedVersion: Found snippet file: snippets/#{template_path}.liquid"
-
return file.content
-
end
-
-
Rails.logger.warn "PublishedVersion: Template file not found: #{template_path}"
-
# Fallback to empty string
-
""
-
end
-
end
-
class Redirect < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Versioning
-
has_paper_trail
-
-
# Enums
-
enum redirect_type: {
-
permanent: 0, # 301 - Permanent redirect
-
temporary: 1, # 302 - Temporary redirect
-
see_other: 2, # 303 - See Other
-
temporary_new: 3 # 307 - Temporary Redirect (preserves method)
-
}
-
-
# Validations
-
validates :from_path, presence: true, uniqueness: { scope: :tenant_id }
-
validates :to_path, presence: true
-
validates :status_code, inclusion: { in: [301, 302, 303, 307, 308] }
-
validate :paths_are_different
-
validate :no_circular_redirects
-
validate :from_path_format
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :inactive, -> { where(active: false) }
-
scope :by_type, ->(type) { where(redirect_type: type) }
-
scope :most_used, -> { order(hits_count: :desc) }
-
scope :recent, -> { order(created_at: :desc) }
-
-
# Callbacks
-
before_validation :normalize_paths
-
after_initialize :set_default_status_code
-
-
# Instance methods
-
-
# Increment hit counter
-
def record_hit!
-
increment!(:hits_count)
-
end
-
-
# Get the appropriate HTTP status code
-
def http_status_code
-
case redirect_type.to_sym
-
when :permanent
-
301
-
when :temporary
-
302
-
when :see_other
-
303
-
when :temporary_new
-
307
-
else
-
status_code || 301
-
end
-
end
-
-
# Check if redirect matches a given path
-
def matches?(path)
-
return false unless active?
-
-
# Exact match
-
return true if from_path == path
-
-
# Wildcard match (if from_path ends with *)
-
if from_path.ends_with?('*')
-
pattern = from_path.chomp('*')
-
return path.starts_with?(pattern)
-
end
-
-
false
-
end
-
-
# Get the destination path for a given request path
-
def destination_for(request_path)
-
# Handle wildcard redirects
-
if from_path.ends_with?('*') && request_path.starts_with?(from_path.chomp('*'))
-
pattern = from_path.chomp('*')
-
remainder = request_path[pattern.length..]
-
return "#{to_path}#{remainder}"
-
end
-
-
to_path
-
end
-
-
private
-
-
def normalize_paths
-
# Ensure paths start with /
-
self.from_path = "/#{from_path}" unless from_path&.start_with?('/')
-
self.to_path = "/#{to_path}" unless to_path&.start_with?('/') || to_path&.start_with?('http')
-
-
# Remove trailing slashes (except for root)
-
self.from_path = from_path.chomp('/') if from_path && from_path.length > 1
-
self.to_path = to_path.chomp('/') if to_path && to_path.length > 1 && !to_path.start_with?('http')
-
end
-
-
def paths_are_different
-
if from_path.present? && to_path.present? && from_path == to_path
-
errors.add(:to_path, "must be different from source path")
-
end
-
end
-
-
def no_circular_redirects
-
return unless from_path.present? && to_path.present?
-
-
# Check if destination redirects back to source
-
destination_redirect = Redirect.active
-
.where(from_path: to_path)
-
.where.not(id: id)
-
.first
-
-
if destination_redirect && destination_redirect.to_path == from_path
-
errors.add(:to_path, "creates a circular redirect")
-
end
-
end
-
-
def from_path_format
-
return unless from_path.present?
-
-
# Allow wildcard at end
-
if from_path.include?('*') && !from_path.ends_with?('*')
-
errors.add(:from_path, "wildcard (*) can only be used at the end")
-
end
-
end
-
-
def set_default_status_code
-
return if status_code.present?
-
-
self.status_code = case redirect_type&.to_sym
-
when :permanent
-
301
-
when :temporary
-
302
-
when :see_other
-
303
-
when :temporary_new
-
307
-
else
-
301
-
end
-
end
-
-
# Class methods
-
-
# Find redirect for a given path
-
def self.find_for_path(path)
-
active.find do |redirect|
-
redirect.matches?(path)
-
end
-
end
-
-
# Import redirects from CSV or array
-
def self.import_redirects(data)
-
imported = 0
-
errors = []
-
-
data.each do |row|
-
redirect = new(
-
from_path: row[:from_path] || row['from_path'],
-
to_path: row[:to_path] || row['to_path'],
-
redirect_type: row[:redirect_type] || row['redirect_type'] || 'permanent',
-
notes: row[:notes] || row['notes']
-
)
-
-
if redirect.save
-
imported += 1
-
else
-
errors << { row: row, errors: redirect.errors.full_messages }
-
end
-
end
-
-
{ imported: imported, errors: errors }
-
end
-
-
# Export to CSV format
-
def self.to_csv
-
require 'csv'
-
-
CSV.generate(headers: true) do |csv|
-
csv << ['From Path', 'To Path', 'Type', 'Status Code', 'Active', 'Hits', 'Notes']
-
-
all.each do |redirect|
-
csv << [
-
redirect.from_path,
-
redirect.to_path,
-
redirect.redirect_type,
-
redirect.status_code,
-
redirect.active,
-
redirect.hits_count,
-
redirect.notes
-
]
-
end
-
end
-
end
-
end
-
class Shortcut < ApplicationRecord
-
# Make tenant optional since shortcuts can be global
-
belongs_to :tenant, optional: true
-
-
CATEGORIES = %w[navigation content tools settings].freeze
-
ACTION_TYPES = %w[navigate execute modal].freeze
-
-
validates :name, presence: true
-
validates :action_type, presence: true, inclusion: { in: ACTION_TYPES }
-
validates :category, inclusion: { in: CATEGORIES }, allow_nil: true
-
-
scope :active, -> { where(active: true) }
-
scope :by_category, ->(category) { where(category: category) }
-
scope :ordered, -> { order(:category, :position, :name) }
-
-
after_initialize :set_defaults, if: :new_record?
-
-
def execute(context = {})
-
case action_type
-
when 'navigate'
-
# Return URL to navigate to
-
action_value
-
when 'execute'
-
# Return JavaScript to execute
-
action_value
-
when 'modal'
-
# Return modal to open
-
action_value
-
end
-
end
-
-
private
-
-
def set_defaults
-
self.active = true if active.nil?
-
self.position ||= 0
-
self.category ||= 'navigation'
-
end
-
end
-
1
class SiteSetting < ApplicationRecord
-
# Multi-tenancy
-
1
acts_as_tenant(:tenant, optional: true)
-
-
# Validations
-
1
validates :key, presence: true
-
1
validates :key, uniqueness: { scope: :tenant_id }
-
1
validates :setting_type, presence: true
-
-
# Setting types
-
1
SETTING_TYPES = %w[string integer boolean text].freeze
-
1
validates :setting_type, inclusion: { in: SETTING_TYPES }
-
-
# Class methods for easy access
-
1
def self.get(key, default = nil)
-
9
then: 0
setting = ActsAsTenant.current_tenant ?
-
else: 9
where(tenant: ActsAsTenant.current_tenant).find_by(key: key) :
-
find_by(key: key)
-
9
then: 0
else: 9
setting ? setting.typed_value : default
-
end
-
-
1
def self.set(key, value, setting_type = 'string')
-
then: 0
setting = ActsAsTenant.current_tenant ?
-
else: 0
where(tenant: ActsAsTenant.current_tenant).find_or_initialize_by(key: key) :
-
find_or_initialize_by(key: key)
-
setting.value = value.to_s
-
setting.setting_type = setting_type
-
then: 0
else: 0
setting.tenant = ActsAsTenant.current_tenant if ActsAsTenant.current_tenant
-
setting.save
-
end
-
-
# Instance methods
-
1
def typed_value
-
case setting_type
-
when: 0
when 'integer'
-
value.to_i
-
when: 0
when 'boolean'
-
value == 'true' || value == '1'
-
when: 0
when 'text', 'string'
-
value
-
else: 0
else
-
value
-
end
-
end
-
end
-
# SlickForm Model
-
# Represents a form created with SlickForms plugin
-
-
class SlickForm < ApplicationRecord
-
# Associations
-
has_many :slick_form_submissions, dependent: :destroy
-
-
# Validations
-
validates :name, presence: true, uniqueness: { scope: :tenant_id }
-
validates :title, presence: true
-
validates :active, inclusion: { in: [true, false] }
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :inactive, -> { where(active: false) }
-
scope :by_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
-
scope :accessible_by, ->(tenant) { tenant ? where(tenant_id: tenant.id) : all }
-
scope :recent, -> { order(created_at: :desc) }
-
-
# JSON fields are handled natively by Rails 7+
-
-
# Callbacks
-
before_save :ensure_defaults
-
-
# Methods
-
-
def field_count
-
fields&.size || 0
-
end
-
-
def has_submissions?
-
submissions_count > 0
-
end
-
-
def conversion_rate
-
return 0.0 unless views_count&.> 0
-
submissions_count.to_f / views_count
-
end
-
-
def duplicate!
-
new_form = dup
-
new_form.name = "#{name} (Copy)"
-
new_form.title = "#{title} (Copy)"
-
new_form.submissions_count = 0
-
new_form.save!
-
new_form
-
end
-
-
def public_url
-
"/plugins/slick_forms/form/#{id}"
-
end
-
-
def embed_url
-
"/plugins/slick_forms/form/#{id}/embed"
-
end
-
-
def submission_url
-
"/plugins/slick_forms/submit/#{id}"
-
end
-
-
private
-
-
def ensure_defaults
-
self.fields ||= []
-
self.settings ||= {}
-
self.submissions_count ||= 0
-
end
-
end
-
# SlickFormSubmission Model
-
# Represents a form submission from SlickForms plugin
-
-
class SlickFormSubmission < ApplicationRecord
-
# Associations
-
belongs_to :slick_form
-
-
# Validations
-
validates :data, presence: true
-
-
# JSON fields are handled natively by Rails 7+
-
-
# Scopes
-
scope :spam, -> { where(spam: true) }
-
scope :ham, -> { where(spam: false) }
-
scope :recent, -> { order(created_at: :desc) }
-
scope :accessible_by, ->(tenant) { tenant ? where(tenant_id: tenant.id) : all }
-
scope :by_tenant, ->(tenant_id) { where(tenant_id: tenant_id) }
-
scope :today, -> { where(created_at: Date.current.beginning_of_day..Date.current.end_of_day) }
-
scope :this_week, -> { where(created_at: 1.week.ago..Time.current) }
-
-
# Methods
-
-
def spam?
-
spam == true
-
end
-
-
def ham?
-
spam == false
-
end
-
-
def mark_as_spam!
-
update!(spam: true)
-
end
-
-
def mark_as_ham!
-
update!(spam: false)
-
end
-
-
def data_field(field_name)
-
return nil unless data.is_a?(Hash)
-
data[field_name.to_s] || data[field_name.to_sym]
-
end
-
-
def email
-
data_field('email')
-
end
-
-
def name
-
data_field('name') || data_field('first_name')
-
end
-
-
def form_title
-
slick_form&.title || 'Unknown Form'
-
end
-
-
def ip_location
-
# This would integrate with a geolocation service
-
'Unknown'
-
end
-
-
def user_agent_parsed
-
# This would parse user agent for browser/OS info
-
user_agent
-
end
-
-
def to_csv_row
-
[
-
id,
-
slick_form_id,
-
form_title,
-
name,
-
email,
-
data.to_json,
-
ip_address,
-
user_agent,
-
spam? ? 'Yes' : 'No',
-
created_at.strftime('%Y-%m-%d %H:%M:%S')
-
]
-
end
-
-
class << self
-
def csv_headers
-
[
-
'ID',
-
'Form ID',
-
'Form Name',
-
'Name',
-
'Email',
-
'Data',
-
'IP Address',
-
'User Agent',
-
'Spam',
-
'Created At'
-
]
-
end
-
-
def export_csv(submissions = all)
-
require 'csv'
-
-
CSV.generate do |csv|
-
csv << csv_headers
-
submissions.each { |submission| csv << submission.to_csv_row }
-
end
-
end
-
-
def spam_count
-
spam.count
-
end
-
-
def ham_count
-
ham.count
-
end
-
-
def total_count
-
count
-
end
-
end
-
end
-
class StorageProvider < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Validations
-
validates :name, presence: true
-
validates :provider_type, presence: true, inclusion: { in: %w[local s3 gcs azure] }
-
validates :config, presence: true
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :ordered, -> { order(:position, :name) }
-
scope :by_type, ->(type) { where(provider_type: type) }
-
-
# Serialization
-
serialize :config, JSON
-
-
# Callbacks
-
before_validation :set_default_position
-
after_update :update_active_storage_config, if: :saved_change_to_active?
-
-
# Methods
-
def local?
-
provider_type == 'local'
-
end
-
-
def s3?
-
provider_type == 's3'
-
end
-
-
def gcs?
-
provider_type == 'gcs'
-
end
-
-
def azure?
-
provider_type == 'azure'
-
end
-
-
def active_storage_service
-
case provider_type
-
when 'local'
-
:local
-
when 's3'
-
:amazon
-
when 'gcs'
-
:google
-
when 'azure'
-
:microsoft
-
else
-
:local
-
end
-
end
-
-
def active_storage_config
-
case provider_type
-
when 'local'
-
{
-
service: :local,
-
root: config['local_path'] || Rails.root.join('storage')
-
}
-
when 's3'
-
{
-
service: :amazon,
-
access_key_id: config['access_key_id'],
-
secret_access_key: config['secret_access_key'],
-
region: config['region'],
-
bucket: config['bucket'],
-
endpoint: config['endpoint']
-
}.compact
-
when 'gcs'
-
{
-
service: :google,
-
project: config['project'],
-
bucket: config['bucket'],
-
credentials: config['credentials']
-
}.compact
-
when 'azure'
-
{
-
service: :microsoft,
-
storage_account_name: config['storage_account_name'],
-
storage_access_key: config['storage_access_key'],
-
container: config['container']
-
}.compact
-
else
-
{ service: :local, root: Rails.root.join('storage') }
-
end
-
end
-
-
private
-
-
def set_default_position
-
self.position ||= (StorageProvider.maximum(:position) || 0) + 1
-
end
-
-
def update_active_storage_config
-
if active?
-
# Deactivate other providers
-
StorageProvider.where.not(id: id).update_all(active: false)
-
-
# Update Rails storage configuration
-
Rails.application.configure do
-
config.active_storage.variant_processor = :mini_magick
-
config.active_storage.service = name.underscore.to_sym
-
-
config.active_storage.services[name.underscore.to_sym] = active_storage_config
-
end
-
end
-
end
-
end
-
class Subscriber < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Versioning
-
has_paper_trail
-
-
# Serialization
-
serialize :metadata, coder: JSON, type: Hash
-
serialize :tags, coder: JSON, type: Array
-
serialize :lists, coder: JSON, type: Array
-
-
# Enums
-
enum status: {
-
pending: 0, # Awaiting confirmation
-
confirmed: 1, # Confirmed and active
-
unsubscribed: 2, # Opted out
-
bounced: 3, # Email bounced
-
complained: 4 # Marked as spam
-
}, _suffix: true
-
-
# Validations
-
validates :email, presence: true,
-
format: { with: URI::MailTo::EMAIL_REGEXP },
-
uniqueness: { scope: :tenant_id, case_sensitive: false }
-
validates :status, presence: true
-
validate :email_not_in_blocklist
-
-
# Scopes
-
scope :confirmed, -> { where(status: 'confirmed') }
-
scope :pending, -> { where(status: 'pending') }
-
scope :unsubscribed, -> { where(status: 'unsubscribed') }
-
scope :bounced, -> { where(status: 'bounced') }
-
scope :complained, -> { where(status: 'complained') }
-
scope :active, -> { where(status: ['confirmed', 'pending']) }
-
scope :by_source, ->(source) { where(source: source) }
-
scope :by_tag, ->(tag) { where("tags LIKE ?", "%#{tag}%") }
-
scope :by_list, ->(list) { where("lists LIKE ?", "%#{list}%") }
-
scope :recent, -> { order(created_at: :desc) }
-
scope :search, ->(query) { where('email LIKE ? OR name LIKE ?', "%#{query}%", "%#{query}%") }
-
-
# Callbacks
-
before_create :generate_unsubscribe_token
-
after_create :send_confirmation_email
-
after_update :handle_status_change
-
-
# Instance methods
-
-
# Confirm subscription
-
def confirm!
-
update!(
-
status: 'confirmed',
-
confirmed_at: Time.current
-
)
-
end
-
-
# Unsubscribe
-
def unsubscribe!
-
update!(
-
status: 'unsubscribed',
-
unsubscribed_at: Time.current
-
)
-
end
-
-
# Resubscribe
-
def resubscribe!
-
update!(
-
status: 'confirmed',
-
unsubscribed_at: nil
-
)
-
end
-
-
# Mark as bounced
-
def mark_bounced!
-
update!(status: 'bounced')
-
end
-
-
# Mark as complained (spam)
-
def mark_complained!
-
update!(status: 'complained')
-
end
-
-
# Add tag
-
def add_tag(tag)
-
self.tags ||= []
-
self.tags << tag unless self.tags.include?(tag)
-
save
-
end
-
-
# Remove tag
-
def remove_tag(tag)
-
self.tags ||= []
-
self.tags.delete(tag)
-
save
-
end
-
-
# Add to list
-
def add_to_list(list)
-
self.lists ||= []
-
self.lists << list unless self.lists.include?(list)
-
save
-
end
-
-
# Remove from list
-
def remove_from_list(list)
-
self.lists ||= []
-
self.lists.delete(list)
-
save
-
end
-
-
# Get unsubscribe URL
-
def unsubscribe_url
-
Rails.application.routes.url_helpers.unsubscribe_url(token: unsubscribe_token)
-
rescue
-
"#"
-
end
-
-
# Check if subscriber can receive emails
-
def can_receive_emails?
-
confirmed_status? && confirmed_at.present?
-
end
-
-
# Get metadata value
-
def get_metadata(key, default = nil)
-
(metadata || {})[key.to_s] || default
-
end
-
-
# Set metadata value
-
def set_metadata(key, value)
-
self.metadata ||= {}
-
self.metadata[key.to_s] = value
-
save
-
end
-
-
private
-
-
def generate_unsubscribe_token
-
self.unsubscribe_token ||= SecureRandom.urlsafe_base64(32)
-
end
-
-
def send_confirmation_email
-
return if confirmed_at.present? # Already confirmed
-
return unless Rails.env.production? || ENV['SEND_CONFIRMATION_EMAILS'] == 'true'
-
-
# Send confirmation email via background job
-
# SubscriberMailer.confirmation_email(self).deliver_later
-
end
-
-
def handle_status_change
-
return unless saved_change_to_status?
-
-
case status.to_sym
-
when :confirmed
-
self.confirmed_at ||= Time.current
-
# Could trigger welcome email
-
when :unsubscribed
-
self.unsubscribed_at ||= Time.current
-
# Could trigger goodbye email
-
end
-
end
-
-
def email_not_in_blocklist
-
# Check against a blocklist (could be a separate model or Redis set)
-
blocklist = ['spam@example.com', 'abuse@example.com']
-
if blocklist.include?(email&.downcase)
-
errors.add(:email, 'is not allowed')
-
end
-
end
-
-
# Class methods
-
-
# Import subscribers from CSV
-
def self.import_from_csv(csv_data)
-
require 'csv'
-
-
imported = 0
-
errors = []
-
-
CSV.parse(csv_data, headers: true).each_with_index do |row, index|
-
subscriber = new(
-
email: row['email'] || row['Email'],
-
name: row['name'] || row['Name'],
-
source: row['source'] || row['Source'] || 'csv_import',
-
status: row['status'] || row['Status'] || 'confirmed'
-
)
-
-
if subscriber.save
-
imported += 1
-
else
-
errors << { row: index + 2, email: row['email'], errors: subscriber.errors.full_messages }
-
end
-
end
-
-
{ imported: imported, errors: errors, total: imported + errors.count }
-
end
-
-
# Export to CSV
-
def self.to_csv
-
require 'csv'
-
-
CSV.generate(headers: true) do |csv|
-
csv << ['Email', 'Name', 'Status', 'Source', 'Confirmed At', 'Tags', 'Lists', 'Created At']
-
-
all.each do |subscriber|
-
csv << [
-
subscriber.email,
-
subscriber.name,
-
subscriber.status,
-
subscriber.source,
-
subscriber.confirmed_at&.strftime('%Y-%m-%d %H:%M'),
-
(subscriber.tags || []).join(', '),
-
(subscriber.lists || []).join(', '),
-
subscriber.created_at.strftime('%Y-%m-%d %H:%M')
-
]
-
end
-
end
-
end
-
-
# Get statistics
-
def self.stats
-
{
-
total: count,
-
confirmed: confirmed.count,
-
pending: pending.count,
-
unsubscribed: unsubscribed.count,
-
bounced: bounced.count,
-
growth_this_month: where('created_at >= ?', 1.month.ago).count,
-
growth_this_week: where('created_at >= ?', 1.week.ago).count,
-
confirmation_rate: count > 0 ? (confirmed.count.to_f / count * 100).round(1) : 0
-
}
-
end
-
end
-
class Taxonomy < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Associations
-
has_many :terms, dependent: :destroy
-
-
# Serialization
-
serialize :object_types, coder: JSON, type: Array
-
serialize :settings, coder: JSON, type: Hash
-
-
# Friendly ID for slugs
-
extend FriendlyId
-
friendly_id :name, use: :slugged
-
-
# Validations
-
validates :name, presence: true
-
validates :slug, presence: true, uniqueness: true
-
-
# Scopes
-
scope :hierarchical, -> { where(hierarchical: true) }
-
scope :flat, -> { where(hierarchical: false) }
-
scope :for_posts, -> { where("object_types LIKE ?", "%Post%") }
-
scope :for_pages, -> { where("object_types LIKE ?", "%Page%") }
-
-
# Callbacks
-
after_initialize :set_defaults, if: :new_record?
-
-
# Methods
-
def should_generate_new_friendly_id?
-
name_changed? || slug.blank?
-
end
-
-
def root_terms
-
terms.where(parent_id: nil).order(name: :asc)
-
end
-
-
def term_count
-
terms.count
-
end
-
-
def applies_to?(object_type)
-
object_types.include?(object_type.to_s)
-
end
-
-
# Built-in taxonomies
-
def self.categories
-
find_or_create_by!(slug: 'category') do |t|
-
t.name = 'Categories'
-
t.description = 'Post categories'
-
t.hierarchical = true
-
t.object_types = ['Post']
-
end
-
end
-
-
def self.tags
-
find_or_create_by!(slug: 'post_tag') do |t|
-
t.name = 'Tags'
-
t.description = 'Post tags'
-
t.hierarchical = false
-
t.object_types = ['Post']
-
end
-
end
-
-
private
-
-
def set_defaults
-
self.hierarchical = false if hierarchical.nil?
-
self.object_types ||= []
-
self.settings ||= {}
-
end
-
end
-
class Template < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
belongs_to :theme
-
-
# Template types
-
TEMPLATE_TYPES = %w[
-
homepage
-
blog_index
-
blog_single
-
page_default
-
page_full_width
-
archive
-
category
-
tag
-
search
-
404
-
header
-
footer
-
sidebar
-
].freeze
-
-
validates :name, presence: true
-
validates :template_type, presence: true, inclusion: { in: TEMPLATE_TYPES }
-
validates :template_type, uniqueness: { scope: :theme_id }
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :by_type, ->(type) { where(template_type: type) }
-
-
# Callbacks
-
after_initialize :set_defaults, if: :new_record?
-
-
# Methods
-
def render_content
-
html_content || default_template
-
end
-
-
private
-
-
def set_defaults
-
self.active = true if active.nil?
-
self.html_content ||= default_template
-
self.css_content ||= default_css
-
self.js_content ||= ''
-
end
-
-
def default_template
-
<<-HTML
-
<div class="container mx-auto px-4 py-8">
-
<h1>Welcome to #{name}</h1>
-
<p>Start customizing this template using the visual editor.</p>
-
</div>
-
HTML
-
end
-
-
def default_css
-
<<-CSS
-
body {
-
font-family: system-ui, -apple-system, sans-serif;
-
line-height: 1.6;
-
color: #333;
-
}
-
.container {
-
max-width: 1200px;
-
}
-
CSS
-
end
-
end
-
1
class Tenant < ApplicationRecord
-
# Serialization
-
1
serialize :settings, coder: JSON, type: Hash
-
-
# Validations
-
1
validates :name, presence: true
-
1
validates :domain, uniqueness: true, allow_nil: true
-
1
validates :subdomain, uniqueness: true, allow_nil: true
-
1
validates :theme, presence: true
-
1
validates :storage_type, inclusion: { in: %w[local s3], message: "%{value} is not a valid storage type" }
-
-
1
validate :must_have_domain_or_subdomain
-
-
# Associations
-
1
has_many :posts, dependent: :destroy
-
1
has_many :pages, dependent: :destroy
-
1
has_many :media, dependent: :destroy
-
1
has_many :comments, dependent: :destroy
-
# Taxonomies instead of separate categories/tags
-
1
has_many :taxonomies, dependent: :destroy
-
1
has_many :terms, through: :taxonomies
-
1
has_many :menus, dependent: :destroy
-
1
has_many :widgets, dependent: :destroy
-
1
has_many :themes, dependent: :destroy
-
1
has_many :site_settings, dependent: :destroy
-
1
has_many :users, dependent: :nullify
-
1
has_many :email_logs
-
-
# Scopes
-
1
scope :active, -> { where(active: true) }
-
1
scope :by_domain, ->(domain) { where(domain: domain) }
-
1
scope :by_subdomain, ->(subdomain) { where(subdomain: subdomain) }
-
-
# Callbacks
-
1
after_initialize :set_defaults, if: :new_record?
-
1
after_create :create_default_settings
-
-
# Class methods
-
1
def self.find_by_request(request)
-
find_by(domain: request.host) || find_by(subdomain: request.subdomains.first)
-
end
-
-
1
def self.current
-
ActsAsTenant.current_tenant
-
end
-
-
# Instance methods
-
1
def activate!
-
update!(active: true)
-
end
-
-
1
def deactivate!
-
update!(active: false)
-
end
-
-
1
def full_url
-
then: 0
if domain.present?
-
else: 0
"https://#{domain}"
-
then: 0
else: 0
elsif subdomain.present?
-
"https://#{subdomain}.#{default_domain}"
-
else
-
nil
-
end
-
end
-
-
1
def default_domain
-
ENV['APP_DOMAIN'] || 'railspress.app'
-
end
-
-
1
def locale_list
-
(locales || 'en').split(',').map(&:strip)
-
end
-
-
1
def locale_list=(value)
-
self.locales = Array(value).join(',')
-
end
-
-
# Storage methods
-
1
def using_s3?
-
storage_type == 's3'
-
end
-
-
1
def using_local_storage?
-
storage_type == 'local'
-
end
-
-
1
def storage_configured?
-
then: 0
if using_s3?
-
storage_bucket.present? && storage_region.present? &&
-
storage_access_key.present? && storage_secret_key.present?
-
else: 0
else
-
true # Local storage is always configured
-
end
-
end
-
-
1
def storage_service
-
then: 0
if using_s3?
-
:amazon
-
else: 0
else
-
:local
-
end
-
end
-
-
# Settings helpers
-
1
def get_setting(key, default = nil)
-
then: 0
else: 0
settings&.dig(key) || default
-
end
-
-
1
def set_setting(key, value)
-
self.settings ||= {}
-
self.settings[key] = value
-
save
-
end
-
-
1
private
-
-
1
def set_defaults
-
self.theme ||= 'default'
-
self.locales ||= 'en'
-
then: 0
else: 0
self.active = true if self.active.nil?
-
self.storage_type ||= 'local'
-
self.settings ||= {}
-
end
-
-
1
def must_have_domain_or_subdomain
-
then: 0
else: 0
if domain.blank? && subdomain.blank?
-
errors.add(:base, "Must have either a domain or subdomain")
-
end
-
end
-
-
1
def create_default_settings
-
# Create default site settings for the tenant
-
default_settings = {
-
'site_title' => name,
-
'site_tagline' => "Powered by #{name}",
-
'posts_per_page' => 10,
-
'default_post_status' => 'draft',
-
'comments_enabled' => true
-
}
-
-
default_settings.each do |key, value|
-
site_settings.find_or_create_by!(key: key) do |setting|
-
setting.value = value.to_s
-
then: 0
else: 0
setting.setting_type = value.is_a?(TrueClass) || value.is_a?(FalseClass) ? 'boolean' : 'string'
-
setting.tenant = self
-
end
-
end
-
end
-
end
-
class Term < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant, optional: true)
-
-
belongs_to :taxonomy
-
belongs_to :parent, class_name: 'Term', optional: true
-
-
# Associations
-
has_many :children, class_name: 'Term', foreign_key: 'parent_id', dependent: :destroy
-
has_many :term_relationships, dependent: :destroy
-
-
# Serialization
-
serialize :metadata, coder: JSON, type: Hash
-
-
# Friendly ID for slugs
-
extend FriendlyId
-
friendly_id :name, use: [:slugged, :scoped], scope: :taxonomy
-
-
# Validations
-
validates :name, presence: true
-
validates :slug, presence: true, uniqueness: { scope: :taxonomy_id }
-
validates :taxonomy, presence: true
-
-
# Scopes
-
scope :root_terms, -> { where(parent_id: nil) }
-
scope :ordered, -> { order(name: :asc) }
-
scope :popular, -> { order(count: :desc) }
-
scope :for_taxonomy, ->(taxonomy_slug) { joins(:taxonomy).where(taxonomies: { slug: taxonomy_slug }) }
-
-
# Callbacks
-
after_initialize :set_defaults, if: :new_record?
-
after_save :update_count
-
-
# Methods
-
def should_generate_new_friendly_id?
-
name_changed? || slug.blank?
-
end
-
-
def hierarchical?
-
taxonomy&.hierarchical?
-
end
-
-
def update_count
-
self.count = term_relationships.count
-
save if count_changed?
-
end
-
-
def breadcrumbs
-
result = [self]
-
current = self
-
while current.parent.present?
-
result.unshift(current.parent)
-
current = current.parent
-
end
-
result
-
end
-
-
# Get all objects (posts/pages) with this term
-
def objects
-
term_relationships.includes(:object).map(&:object).compact
-
end
-
-
# Get objects of specific type
-
def objects_of_type(type)
-
term_relationships.where(object_type: type).includes(:object).map(&:object).compact
-
end
-
-
# Get posts associated with this term
-
def posts
-
Post.joins(:term_relationships).where(term_relationships: { term_id: id })
-
end
-
-
# Convert Term to Liquid-compatible hash
-
def to_liquid
-
{
-
'id' => id,
-
'name' => name,
-
'slug' => slug,
-
'description' => description,
-
'count' => count,
-
'taxonomy' => taxonomy&.name,
-
'taxonomy_slug' => taxonomy&.slug,
-
'parent_id' => parent_id,
-
'children' => children.to_a, # Convert AssociationRelation to array
-
'metadata' => metadata || {}
-
}
-
end
-
-
# Generate URL for the term
-
def url
-
# This would need to be implemented based on your routing
-
"/#{taxonomy&.slug}/#{slug}"
-
end
-
-
# Make methods public for Liquid access
-
public :url, :to_liquid
-
-
private
-
-
def set_defaults
-
self.count ||= 0
-
self.metadata ||= {}
-
end
-
end
-
class TermRelationship < ApplicationRecord
-
belongs_to :term, counter_cache: :count
-
belongs_to :object, polymorphic: true
-
-
validates :term, presence: true
-
validates :object, presence: true
-
validates :term_id, uniqueness: { scope: [:object_type, :object_id] }
-
-
# Callbacks
-
after_create :update_term_count
-
after_destroy :update_term_count
-
-
private
-
-
def update_term_count
-
term.update_count
-
end
-
end
-
1
class Theme < ApplicationRecord
-
# Multi-tenancy
-
1
acts_as_tenant(:tenant)
-
-
# Associations
-
1
has_many :templates, dependent: :destroy
-
1
has_many :theme_versions, foreign_key: :theme_name, primary_key: :name, dependent: :destroy
-
1
has_many :theme_files, foreign_key: :theme_name, primary_key: :name, dependent: :destroy
-
-
# Serialization
-
1
serialize :config, coder: JSON, type: Hash
-
-
# Validations
-
1
validates :name, presence: true, uniqueness: true
-
1
validates :slug, presence: true, uniqueness: true
-
1
validates :version, presence: true
-
-
# Scopes
-
2
scope :active, -> { where(active: true) }
-
-
# Callbacks
-
1
after_initialize :set_defaults, if: :new_record?
-
1
before_save :deactivate_others, if: :active?
-
1
before_save :set_slug_from_name
-
-
# Methods
-
1
def self.current
-
active.first || first
-
end
-
-
1
def activate!
-
Theme.where.not(id: id).update_all(active: false)
-
success = update(active: true)
-
-
if success
-
then: 0
# Create PublishedThemeVersion if it doesn't exist
-
ensure_published_version_exists!
-
true
-
else: 0
else
-
false
-
end
-
end
-
-
1
def get_file(file_path)
-
live_version = theme_versions.live.first
-
then: 0
else: 0
live_version&.file_content(file_path)
-
end
-
-
1
def get_parsed_file(file_path)
-
content = get_file(file_path)
-
else: 0
then: 0
return nil unless content
-
-
then: 0
if file_path.end_with?('.json')
-
JSON.parse(content)
-
else: 0
else
-
content
-
end
-
rescue JSON::ParserError
-
nil
-
end
-
-
1
def file_tree
-
ThemesManager.new.file_tree(name)
-
end
-
-
1
def live_version
-
theme_versions.live.first
-
end
-
-
1
def has_update_available?
-
ThemesManager.new.check_for_updates(self)
-
end
-
-
1
def get_template(template_type)
-
templates.by_type(template_type).active.first
-
end
-
-
# Ensure a PublishedThemeVersion exists for this theme
-
1
def ensure_published_version_exists!
-
# Check if we already have a PublishedThemeVersion for this theme
-
then: 0
else: 0
return if PublishedThemeVersion.where(theme: self).exists?
-
-
Rails.logger.info "Creating initial PublishedThemeVersion for theme: #{name}"
-
-
# Create initial PublishedThemeVersion
-
published_version = PublishedThemeVersion.create!(
-
theme: self,
-
version_number: 1,
-
published_at: Time.current,
-
published_by: User.first, # TODO: Use current user if available
-
tenant: tenant
-
)
-
-
# Copy all files from this theme's version to PublishedThemeFile
-
manager = ThemesManager.new
-
theme_version = theme_versions.live.first
-
-
then: 0
if theme_version
-
theme_version.theme_files.each do |theme_file|
-
# Convert absolute path to relative path
-
relative_path = theme_file.file_path.gsub(/^.*\/themes\/[^\/]+\//, '')
-
-
# Get content using relative path and theme name
-
content = manager.get_file(relative_path, name)
-
else: 0
then: 0
next unless content
-
-
PublishedThemeFile.create!(
-
published_theme_version: published_version,
-
file_path: relative_path,
-
file_type: theme_file.file_type,
-
content: content,
-
checksum: Digest::MD5.hexdigest(content)
-
)
-
end
-
-
Rails.logger.info "Created initial PublishedThemeVersion #{published_version.id} with #{published_version.published_theme_files.count} files"
-
else: 0
else
-
Rails.logger.warn "No theme version found for #{name}"
-
end
-
-
published_version
-
end
-
-
1
def published_version
-
PublishedThemeVersion.where(theme: self).first
-
end
-
-
1
private
-
-
1
def set_defaults
-
then: 0
else: 0
self.active = false if active.nil?
-
self.config ||= {}
-
end
-
-
1
def set_slug_from_name
-
then: 0
else: 0
self.slug = name.parameterize if name.present? && slug.blank?
-
end
-
-
1
def deactivate_others
-
then: 0
else: 0
Theme.where.not(id: id).update_all(active: false) if active_changed? && active?
-
end
-
end
-
class ThemeFile < ApplicationRecord
-
belongs_to :theme_version, optional: true
-
has_many :theme_file_versions, dependent: :destroy
-
-
# Validations
-
validates :theme_name, presence: true
-
validates :file_path, presence: true, uniqueness: { scope: [:theme_name, :theme_version_id] }
-
validates :file_type, presence: true
-
validates :current_checksum, presence: true
-
-
# Scopes
-
scope :for_theme, ->(theme_name) { where(theme_name: theme_name) }
-
scope :templates, -> { where(file_type: 'template') }
-
scope :sections, -> { where(file_type: 'section') }
-
scope :layouts, -> { where(file_type: 'layout') }
-
scope :assets, -> { where(file_type: 'asset') }
-
scope :configs, -> { where(file_type: 'config') }
-
-
# Methods
-
def current_content
-
latest_version&.content
-
end
-
-
def latest_version
-
theme_file_versions.latest.first
-
end
-
-
def version_at(version_number)
-
theme_file_versions.find_by(version_number: version_number)
-
end
-
-
def create_new_version(content, user, theme_version = nil)
-
ThemeFileVersion.create_version(theme_name, file_path, content, user, theme_version)
-
end
-
-
def liquid_content?
-
file_path.end_with?('.liquid')
-
end
-
-
def json_content?
-
file_path.end_with?('.json')
-
end
-
-
def css_content?
-
file_path.end_with?('.css')
-
end
-
-
def js_content?
-
file_path.end_with?('.js')
-
end
-
-
def parsed_content
-
return nil unless current_content
-
-
if json_content?
-
JSON.parse(current_content)
-
elsif liquid_content?
-
current_content
-
else
-
current_content
-
end
-
rescue JSON::ParserError
-
nil
-
end
-
-
def parsed_schema
-
return nil unless liquid_content? && current_content
-
-
schema_match = current_content.match(/\{%\s*schema\s*%\}(.*?)\{%\s*endschema\s*%\}/m)
-
return nil unless schema_match
-
-
JSON.parse(schema_match[1])
-
rescue JSON::ParserError
-
nil
-
end
-
-
def self.find_or_create_from_path(theme_name, file_path)
-
find_or_create_by(theme_name: theme_name, file_path: file_path) do |file|
-
file.file_type = determine_file_type(file_path)
-
end
-
end
-
-
private
-
-
def self.determine_file_type(file_path)
-
if file_path.start_with?('templates/')
-
'template'
-
elsif file_path.start_with?('sections/')
-
'section'
-
elsif file_path.start_with?('layout/')
-
'layout'
-
elsif file_path.start_with?('assets/')
-
'asset'
-
elsif file_path.start_with?('config/')
-
'config'
-
else
-
'other'
-
end
-
end
-
end
-
class ThemeFileVersion < ApplicationRecord
-
belongs_to :user
-
belongs_to :theme_file, optional: true
-
belongs_to :theme_version, optional: true
-
-
# Validations
-
validates :version_number, presence: true, uniqueness: { scope: :theme_file_id }
-
validates :file_checksum, presence: true
-
-
# Scopes
-
scope :latest, -> { order(version_number: :desc) }
-
-
# Callbacks
-
before_create :set_version_number
-
after_create :update_theme_file_version
-
-
# Methods
-
def self.create_version(theme_file, content, user, theme_version = nil)
-
create!(
-
theme_file: theme_file,
-
content: content,
-
file_size: content.bytesize,
-
user: user,
-
theme_version: theme_version,
-
change_summary: "Version #{version_number}"
-
)
-
end
-
-
private
-
-
def set_version_number
-
latest = self.class.where(theme_file: theme_file).latest.first
-
self.version_number = latest ? latest.version_number + 1 : 1
-
end
-
-
def update_theme_file_version
-
theme_file.update!(current_version: version_number)
-
end
-
-
def determine_file_type(file_path)
-
if file_path.start_with?('templates/')
-
'template'
-
elsif file_path.start_with?('sections/')
-
'section'
-
elsif file_path.start_with?('layout/')
-
'layout'
-
elsif file_path.start_with?('assets/')
-
'asset'
-
elsif file_path.start_with?('config/')
-
'config'
-
else
-
'other'
-
end
-
end
-
end
-
class ThemePreview < ApplicationRecord
-
belongs_to :builder_theme
-
belongs_to :tenant
-
has_many :theme_preview_sections, dependent: :destroy
-
has_many :theme_preview_files, dependent: :destroy
-
-
validates :template_name, presence: true, uniqueness: { scope: :builder_theme_id }
-
-
# Initialize preview with files and sections from builder theme
-
def initialize_from_builder_theme!
-
# Copy files from builder theme
-
ThemePreviewFile.copy_from_builder_theme(builder_theme, template_name)
-
-
# Copy sections from builder theme
-
ThemePreviewSection.copy_from_builder_theme(self, builder_theme, template_name)
-
end
-
-
# Update section settings
-
def update_section_settings(section_id, settings)
-
section = theme_preview_sections.find_by(section_id: section_id)
-
-
if section
-
# Update existing section
-
section.update_settings(settings)
-
Rails.logger.info "Updated existing section #{section_id} with settings: #{settings.inspect}"
-
else
-
# Create new section if it doesn't exist
-
new_section = theme_preview_sections.create!(
-
section_id: section_id,
-
section_type: section_id, # Default type
-
settings: settings,
-
position: theme_preview_sections.count
-
)
-
Rails.logger.info "Created new section #{section_id} with settings: #{settings.inspect}"
-
new_section
-
end
-
end
-
-
# Update section order
-
def update_section_order(section_ids)
-
ThemePreviewSection.reorder_sections(self, section_ids)
-
end
-
-
# Get sections ordered by position
-
def ordered_sections
-
theme_preview_sections.ordered_by_position(self)
-
end
-
-
# Get template content as JSON (for compatibility)
-
def template_content
-
sections_data = {}
-
section_order = []
-
-
ordered_sections.each do |section|
-
section_data = {
-
'type' => section.section_type,
-
'settings' => section.settings
-
}
-
-
# Add blocks if the section has them
-
if section.blocks.any?
-
section_data['blocks'] = section.blocks.map do |block|
-
{
-
'id' => block.block_id,
-
'type' => block.block_type,
-
'settings' => block.settings
-
}
-
end
-
end
-
-
sections_data[section.section_id] = section_data
-
section_order << section.section_id
-
end
-
-
{
-
'name' => template_name.humanize,
-
'sections' => sections_data,
-
'order' => section_order,
-
'theme_settings' => self.theme_settings_json || {}
-
}
-
end
-
-
# Class methods for managing previews
-
def self.find_or_create_for_builder(builder_theme, template_name = 'index')
-
preview = find_by(
-
builder_theme: builder_theme,
-
template_name: template_name
-
)
-
-
unless preview
-
preview = create!(
-
builder_theme: builder_theme,
-
tenant: builder_theme.tenant,
-
template_name: template_name
-
)
-
-
# Initialize with files and sections from builder theme
-
preview.initialize_from_builder_theme!
-
else
-
# Ensure existing preview has sections (in case it was created before sections were added)
-
if preview.theme_preview_sections.empty?
-
Rails.logger.info "Initializing empty ThemePreview sections from BuilderTheme"
-
preview.initialize_from_builder_theme!
-
end
-
end
-
-
preview
-
end
-
-
# Clean up duplicate sections
-
def cleanup_duplicates!
-
Rails.logger.info "=== CLEANING UP DUPLICATE SECTIONS ==="
-
-
# Remove duplicate sections (keep the latest one)
-
section_ids = theme_preview_sections.pluck(:section_id)
-
duplicate_section_ids = section_ids.select { |id| section_ids.count(id) > 1 }.uniq
-
-
Rails.logger.info "Found duplicate section IDs: #{duplicate_section_ids}"
-
-
duplicate_section_ids.each do |section_id|
-
duplicates = theme_preview_sections.where(section_id: section_id).order(:updated_at)
-
Rails.logger.info "Cleaning up #{duplicates.count} duplicates for section #{section_id}"
-
# Keep the latest one, remove the rest
-
duplicates.offset(1).destroy_all
-
end
-
-
# Note: ThemePreviewFile belongs to BuilderTheme, not ThemePreview
-
# So we don't need to clean up files here
-
end
-
-
# Class method to clean up all duplicates across all previews
-
def self.cleanup_all_duplicates!
-
all.each(&:cleanup_duplicates!)
-
end
-
end
-
class ThemePreviewBlock < ApplicationRecord
-
belongs_to :theme_preview_section
-
-
validates :block_id, presence: true, uniqueness: { scope: :theme_preview_section_id }
-
validates :block_type, presence: true
-
validates :position, presence: true
-
-
serialize :settings, coder: JSON, type: Hash
-
-
# Ensure settings is never nil to satisfy the NOT NULL constraint
-
before_validation :ensure_settings_not_nil
-
-
private
-
-
def ensure_settings_not_nil
-
# Ensure settings is never nil or empty to satisfy the NOT NULL constraint
-
# The JSON serializer converts empty hashes to nil, so we need a non-empty hash
-
if settings.nil? || settings.empty?
-
self.settings = { 'initialized' => true }
-
end
-
end
-
end
-
class ThemePreviewFile < ApplicationRecord
-
belongs_to :builder_theme
-
belongs_to :tenant
-
-
validates :file_path, presence: true, uniqueness: { scope: :builder_theme_id }
-
validates :file_type, presence: true
-
validates :content, presence: true
-
-
# Class method to copy files from BuilderTheme to ThemePreview
-
def self.copy_from_builder_theme(builder_theme, template_name)
-
# Get all files from the published theme (since BuilderTheme files might be empty)
-
published_version = builder_theme.published_version
-
published_files = published_version.published_theme_files
-
published_file_paths = published_files.pluck(:file_path)
-
-
# Get existing preview files
-
existing_preview_files = where(builder_theme: builder_theme).index_by(&:file_path)
-
-
# Track which files we've processed
-
processed_file_paths = []
-
-
published_files.each do |published_file|
-
processed_file_paths << published_file.file_path
-
-
if existing_preview_files[published_file.file_path]
-
# Update existing preview file if content has changed
-
existing_file = existing_preview_files[published_file.file_path]
-
if existing_file.content != published_file.content
-
existing_file.update!(
-
content: published_file.content,
-
file_type: published_file.file_type
-
)
-
end
-
else
-
# Create new preview file
-
create!(
-
builder_theme: builder_theme,
-
tenant: builder_theme.tenant,
-
file_path: published_file.file_path,
-
file_type: published_file.file_type,
-
content: published_file.content
-
)
-
end
-
end
-
-
# Remove preview files that no longer exist in the published theme
-
files_to_remove = existing_preview_files.keys - processed_file_paths
-
files_to_remove.each do |file_path|
-
existing_preview_files[file_path]&.destroy!
-
end
-
end
-
-
# Get template file content for a specific template
-
def self.get_template_content(builder_theme, template_name)
-
template_file = find_by(
-
builder_theme: builder_theme,
-
file_path: "templates/#{template_name}.json"
-
)
-
-
if template_file
-
JSON.parse(template_file.content)
-
else
-
# Return empty structure if no template exists
-
{
-
'name' => template_name.humanize,
-
'sections' => {},
-
'order' => []
-
}
-
end
-
end
-
-
# Update template file content
-
def self.update_template_content(builder_theme, template_name, content)
-
template_file = find_or_create_by(
-
builder_theme: builder_theme,
-
file_path: "templates/#{template_name}.json",
-
file_type: 'template'
-
) do |file|
-
file.tenant = builder_theme.tenant
-
file.content = content.to_json
-
end
-
-
template_file.update!(content: content.to_json)
-
template_file
-
end
-
end
-
class ThemePreviewSection < ApplicationRecord
-
belongs_to :theme_preview
-
has_many :theme_preview_blocks, dependent: :destroy
-
-
validates :section_id, presence: true, uniqueness: { scope: :theme_preview_id }
-
validates :section_type, presence: true
-
validates :position, presence: true
-
-
serialize :settings, coder: JSON, type: Hash
-
-
# Ensure settings is never nil to satisfy the NOT NULL constraint
-
before_validation :ensure_settings_not_nil
-
-
# Get blocks ordered by position
-
def blocks
-
theme_preview_blocks.order(:position)
-
end
-
-
# Class method to copy sections from BuilderTheme to ThemePreview
-
def self.copy_from_builder_theme(theme_preview, builder_theme, template_name)
-
# Get the template file from published theme files (since BuilderTheme files might be empty)
-
published_version = builder_theme.published_version
-
template_file = published_version.published_theme_files.find_by(file_path: "templates/#{template_name}.json")
-
-
if template_file
-
template_content = JSON.parse(template_file.content)
-
sections = template_content['sections'] || {}
-
section_order = (template_content['order'] || sections.keys).uniq # Remove duplicates
-
-
# Get existing sections in preview
-
existing_sections = where(theme_preview: theme_preview).index_by(&:section_id)
-
-
# Track which sections we've processed
-
processed_section_ids = []
-
-
# Create or update sections in the preview
-
section_order.each_with_index do |section_id, index|
-
section_data = sections[section_id]
-
next unless section_data
-
-
processed_section_ids << section_id
-
-
if existing_sections[section_id]
-
# Update existing section
-
existing_sections[section_id].update!(
-
section_type: section_data['type'] || section_id,
-
settings: section_data['settings'] || {},
-
position: index
-
)
-
else
-
# Create new section
-
create!(
-
theme_preview: theme_preview,
-
section_id: section_id,
-
section_type: section_data['type'] || section_id,
-
settings: section_data['settings'] || {},
-
position: index
-
)
-
end
-
end
-
-
# Remove sections that no longer exist in the builder theme
-
sections_to_remove = existing_sections.keys - processed_section_ids
-
sections_to_remove.each do |section_id|
-
existing_sections[section_id]&.destroy!
-
end
-
else
-
# If no template file exists, clear all existing sections
-
where(theme_preview: theme_preview).destroy_all
-
end
-
end
-
-
# Update section settings
-
def update_settings(new_settings)
-
self.settings = new_settings
-
save!
-
end
-
-
# Reorder sections
-
def self.reorder_sections(theme_preview, section_ids)
-
section_ids.each_with_index do |section_id, index|
-
section = find_by(theme_preview: theme_preview, section_id: section_id)
-
section&.update!(position: index)
-
end
-
end
-
-
# Get sections ordered by position
-
def self.ordered_by_position(theme_preview)
-
where(theme_preview: theme_preview).order(:position)
-
end
-
-
private
-
-
def ensure_settings_not_nil
-
# Ensure settings is never nil or empty to satisfy the NOT NULL constraint
-
# The JSON serializer converts empty hashes to nil, so we need a non-empty hash
-
if settings.nil? || settings.empty?
-
self.settings = { 'initialized' => true }
-
end
-
end
-
end
-
class ThemeVersion < ApplicationRecord
-
belongs_to :user
-
has_many :theme_file_versions, dependent: :nullify
-
has_many :theme_files, dependent: :destroy
-
-
# Validations
-
validates :theme_name, presence: true
-
validates :version, presence: true
-
validates :user_id, presence: true
-
-
# Scopes
-
scope :live, -> { where(is_live: true) }
-
scope :preview, -> { where(is_preview: true) }
-
scope :published, -> { where.not(published_at: nil) }
-
scope :for_theme, ->(theme_name) { where(theme_name: theme_name) }
-
-
# Callbacks
-
before_create :generate_version_number
-
after_create :snapshot_theme_files
-
-
# Methods
-
def self.create_preview(theme_name, user, changes = {})
-
create!(
-
theme_name: theme_name,
-
user: user,
-
is_preview: true,
-
is_live: false,
-
change_summary: changes[:summary] || "Preview version"
-
)
-
end
-
-
def self.create_live_version(theme_name, user, changes = {})
-
# Deactivate current live version
-
live.for_theme(theme_name).update_all(is_live: false)
-
-
create!(
-
theme_name: theme_name,
-
user: user,
-
is_preview: false,
-
is_live: true,
-
published_at: Time.current,
-
change_summary: changes[:summary] || "Published version"
-
)
-
end
-
-
def publish!
-
# Deactivate current live version
-
self.class.live.for_theme(theme_name).update_all(is_live: false)
-
-
# Make this version live
-
update!(
-
is_live: true,
-
is_preview: false,
-
published_at: Time.current
-
)
-
end
-
-
def file_content(file_path)
-
# Try exact match first (for full paths)
-
theme_file = theme_files.find_by(file_path: file_path)
-
return theme_file.theme_file_versions.latest.first&.content if theme_file
-
-
# Try to find by matching the end of the path (for legacy relative paths)
-
theme_file = theme_files.find { |file| file.file_path.end_with?("/#{file_path}") }
-
return nil unless theme_file
-
-
theme_file.theme_file_versions.latest.first&.content
-
end
-
-
def template_data(template_type)
-
# Build full path for template file - use lowercase theme name for filesystem
-
theme_path = Rails.root.join('app', 'themes', theme_name.downcase)
-
full_path = File.join(theme_path, "templates/#{template_type}.json")
-
-
content = file_content(full_path)
-
content ? JSON.parse(content) : {}
-
rescue JSON::ParserError
-
{}
-
end
-
-
def section_content(section_type)
-
# Build full path for section file - use lowercase theme name for filesystem
-
theme_path = Rails.root.join('app', 'themes', theme_name.downcase)
-
full_path = File.join(theme_path, "sections/#{section_type}.liquid")
-
-
file_content(full_path) || ''
-
end
-
-
def layout_content
-
# Build full path for layout file - use lowercase theme name for filesystem
-
theme_path = Rails.root.join('app', 'themes', theme_name.downcase)
-
full_path = File.join(theme_path, "layout/theme.liquid")
-
-
file_content(full_path) || ''
-
end
-
-
def assets
-
# Build full paths for asset files - use lowercase theme name for filesystem
-
theme_path = Rails.root.join('app', 'themes', theme_name.downcase)
-
-
{
-
css: file_content(File.join(theme_path, "assets/theme.css")) || '',
-
js: file_content(File.join(theme_path, "assets/theme.js")) || ''
-
}
-
end
-
-
def theme_files
-
ThemeFile.where(theme_version: self)
-
end
-
-
def templates
-
theme_file_versions.joins(:theme_file).merge(ThemeFile.templates)
-
end
-
-
def sections
-
theme_file_versions.joins(:theme_file).merge(ThemeFile.sections)
-
end
-
-
def layouts
-
theme_file_versions.joins(:theme_file).merge(ThemeFile.layouts)
-
end
-
-
def assets_files
-
theme_file_versions.joins(:theme_file).merge(ThemeFile.assets)
-
end
-
-
private
-
-
def generate_version_number
-
last_version = self.class.for_theme(theme_name).order(:created_at).last
-
if last_version
-
version_parts = last_version.version.split('.')
-
version_parts[2] = (version_parts[2].to_i + 1).to_s
-
self.version = version_parts.join('.')
-
else
-
self.version = "1.0.0"
-
end
-
end
-
-
def snapshot_theme_files
-
ThemeVersionService.new(self).snapshot_theme_files
-
end
-
end
-
class ThemeVersionFile < ApplicationRecord
-
belongs_to :theme_version
-
-
# Validations
-
validates :file_path, presence: true
-
validates :file_type, presence: true
-
-
# Scopes
-
scope :templates, -> { where(file_type: 'template') }
-
scope :sections, -> { where(file_type: 'section') }
-
scope :layouts, -> { where(file_type: 'layout') }
-
scope :assets, -> { where(file_type: 'asset') }
-
scope :configs, -> { where(file_type: 'config') }
-
-
# Methods
-
def self.create_from_file(theme_version, file_path, content)
-
file_type = determine_file_type(file_path)
-
-
create!(
-
theme_version: theme_version,
-
file_path: file_path,
-
file_type: file_type,
-
content: content,
-
file_size: content.bytesize
-
)
-
end
-
-
def liquid_content?
-
file_path.end_with?('.liquid')
-
end
-
-
def json_content?
-
file_path.end_with?('.json')
-
end
-
-
def css_content?
-
file_path.end_with?('.css')
-
end
-
-
def js_content?
-
file_path.end_with?('.js')
-
end
-
-
def parsed_json
-
return nil unless json_content?
-
JSON.parse(content)
-
rescue JSON::ParserError
-
nil
-
end
-
-
def parsed_schema
-
return nil unless liquid_content?
-
-
# Extract schema from liquid content
-
schema_match = content.match(/\{%\s*schema\s*%\}(.*?)\{%\s*endschema\s*%\}/m)
-
return nil unless schema_match
-
-
JSON.parse(schema_match[1])
-
rescue JSON::ParserError
-
nil
-
end
-
-
private
-
-
def self.determine_file_type(file_path)
-
if file_path.start_with?('templates/')
-
'template'
-
elsif file_path.start_with?('sections/')
-
'section'
-
elsif file_path.start_with?('layout/')
-
'layout'
-
elsif file_path.start_with?('assets/')
-
'asset'
-
elsif file_path.start_with?('config/')
-
'config'
-
else
-
'other'
-
end
-
end
-
end
-
class TrashSetting < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Validations
-
validates :cleanup_after_days, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 365 }
-
-
# Callbacks
-
before_validation :set_defaults
-
-
# Class methods
-
def self.current
-
find_by(tenant: ActsAsTenant.current_tenant) || create_default!
-
end
-
-
def self.create_default!
-
create!(
-
auto_cleanup_enabled: true,
-
cleanup_after_days: 30,
-
tenant: ActsAsTenant.current_tenant
-
)
-
end
-
-
# Instance methods
-
def cleanup_after_hours
-
cleanup_after_days * 24
-
end
-
-
def cleanup_after_minutes
-
cleanup_after_hours * 60
-
end
-
-
def cleanup_threshold
-
cleanup_after_days.days.ago
-
end
-
-
def should_cleanup?(deleted_at)
-
return false unless auto_cleanup_enabled?
-
deleted_at < cleanup_threshold
-
end
-
-
private
-
-
def set_defaults
-
self.auto_cleanup_enabled = true if auto_cleanup_enabled.nil?
-
self.cleanup_after_days ||= 30
-
end
-
end
-
class Upload < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
belongs_to :user
-
belongs_to :storage_provider, optional: true
-
-
# ActiveStorage for file attachment
-
has_one_attached :file
-
-
# Serialization
-
serialize :variants, coder: JSON, type: Hash
-
-
# Relationships
-
has_many :media, dependent: :destroy
-
-
# Validations
-
validates :title, presence: true
-
validates :file, presence: true
-
-
# Scopes
-
scope :quarantined, -> { where(quarantined: true) }
-
scope :approved, -> { where(quarantined: [false, nil]) }
-
-
# Callbacks
-
after_commit :trigger_upload_hooks, on: [:create, :update], if: -> { file.attached? }
-
before_validation :configure_storage, on: :create
-
-
# Scopes
-
scope :images, -> { joins(file_attachment: :blob).where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] }) }
-
scope :videos, -> { joins(file_attachment: :blob).where(active_storage_blobs: { content_type: ['video/mp4', 'video/webm'] }) }
-
scope :documents, -> { joins(file_attachment: :blob).where(active_storage_blobs: { content_type: ['application/pdf', 'application/msword'] }) }
-
scope :recent, -> { order(created_at: :desc) }
-
-
# Methods
-
def image?
-
file.attached? && file.content_type&.start_with?('image/')
-
end
-
-
def video?
-
file.attached? && file.content_type&.start_with?('video/')
-
end
-
-
def document?
-
file.attached? && file.content_type&.start_with?('application/')
-
end
-
-
def file_size
-
file.attached? ? file.byte_size : 0
-
end
-
-
def content_type
-
file.attached? ? file.content_type : nil
-
end
-
-
def filename
-
file.attached? ? file.filename.to_s : nil
-
end
-
-
def url
-
return nil unless file.attached?
-
-
# Check if CDN is enabled
-
storage_config = StorageConfigurationService.new
-
if storage_config.cdn_enabled?
-
# Return CDN URL
-
cdn_base = storage_config.cdn_url.chomp('/')
-
"#{cdn_base}#{Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true)}"
-
else
-
# Return regular Rails blob path
-
Rails.application.routes.url_helpers.rails_blob_path(file, only_path: true)
-
end
-
end
-
-
def quarantined?
-
quarantined == true
-
end
-
-
def approved?
-
!quarantined?
-
end
-
-
def approve!
-
update!(quarantined: false, quarantine_reason: nil)
-
end
-
-
def reject!
-
destroy!
-
end
-
-
# Variant methods
-
def has_variant?(format)
-
variants&.key?(format.to_s)
-
end
-
-
def variant_url(format)
-
return nil unless has_variant?(format)
-
-
blob_id = variants[format.to_s]['blob_id']
-
blob = ActiveStorage::Blob.find_by(id: blob_id)
-
return nil unless blob
-
-
storage_config = StorageConfigurationService.new
-
if storage_config.cdn_enabled?
-
cdn_base = storage_config.cdn_url.chomp('/')
-
"#{cdn_base}#{Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)}"
-
else
-
Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
-
end
-
end
-
-
def webp_url
-
variant_url('webp')
-
end
-
-
def avif_url
-
variant_url('avif')
-
end
-
-
def optimized_url
-
# Return the best available format based on browser support
-
# This would typically be handled by a helper or view
-
avif_url || webp_url || url
-
end
-
-
# Responsive variant methods
-
def responsive_variant_url(format, width)
-
return nil unless variants&.dig("#{format}_#{width}w")
-
-
blob_id = variants["#{format}_#{width}w"]['blob_id']
-
blob = ActiveStorage::Blob.find_by(id: blob_id)
-
return nil unless blob
-
-
storage_config = StorageConfigurationService.new
-
if storage_config.cdn_enabled?
-
cdn_base = storage_config.cdn_url.chomp('/')
-
"#{cdn_base}#{Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)}"
-
else
-
Rails.application.routes.url_helpers.rails_blob_path(blob, only_path: true)
-
end
-
end
-
-
def responsive_webp_url(width)
-
responsive_variant_url('webp', width)
-
end
-
-
def responsive_avif_url(width)
-
responsive_variant_url('avif', width)
-
end
-
-
def responsive_original_url(width)
-
responsive_variant_url('original', width)
-
end
-
-
# Generate srcset for responsive images
-
def generate_srcset(format = 'auto', breakpoints = [320, 640, 768, 1024, 1200, 1920])
-
srcset_parts = []
-
-
breakpoints.each do |width|
-
url = case format
-
when 'avif'
-
responsive_avif_url(width) || avif_url
-
when 'webp'
-
responsive_webp_url(width) || webp_url
-
when 'original'
-
responsive_original_url(width) || url
-
else # auto
-
responsive_avif_url(width) || responsive_webp_url(width) || responsive_original_url(width) || url
-
end
-
-
srcset_parts << "#{url} #{width}w" if url
-
end
-
-
srcset_parts.join(', ')
-
end
-
-
# Get available responsive variants
-
def available_responsive_variants
-
return {} unless variants
-
-
variants.select { |key, _| key.include?('_') && key.include?('w') }
-
end
-
-
# Core image optimization method for uploads
-
def optimize_image_if_needed
-
return unless image?
-
return unless file&.attached?
-
-
# Check if optimization is enabled in settings
-
storage_config = StorageConfigurationService.new
-
return unless storage_config.auto_optimize_enabled?
-
-
# Check media settings
-
return unless SiteSetting.get('auto_optimize_images', false)
-
-
# Find associated medium or create one for optimization
-
medium = media.first
-
if medium
-
# Use existing medium
-
OptimizeImageJob.perform_later(medium_id: medium.id)
-
else
-
# Create a temporary medium for optimization
-
temp_medium = Medium.create!(
-
title: title,
-
description: description,
-
alt_text: alt_text,
-
user: user,
-
upload: self
-
)
-
OptimizeImageJob.perform_later(medium_id: temp_medium.id)
-
end
-
-
Rails.logger.info "Queued image optimization for upload #{id} (core system)"
-
end
-
-
def trigger_upload_hooks
-
Railspress::PluginSystem.do_action('upload_created', self) if saved_change_to_id?
-
Railspress::PluginSystem.do_action('upload_updated', self)
-
-
# Core image optimization for uploads (baked into system)
-
optimize_image_if_needed if saved_change_to_id?
-
end
-
-
private
-
-
def configure_storage
-
# Configure storage based on current settings
-
storage_config = StorageConfigurationService.new
-
storage_config.configure_active_storage
-
end
-
end
-
class UploadSecurity < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Serialization
-
serialize :allowed_extensions, JSON
-
serialize :blocked_extensions, JSON
-
serialize :allowed_mime_types, JSON
-
serialize :blocked_mime_types, JSON
-
-
# Validations
-
validates :max_file_size, presence: true, numericality: { greater_than: 0 }
-
-
# Callbacks
-
before_validation :set_defaults
-
after_update :update_global_settings
-
-
# Default values
-
DEFAULT_ALLOWED_EXTENSIONS = %w[jpg jpeg png gif webp pdf doc docx txt csv xlsx ppt pptx zip].freeze
-
DEFAULT_BLOCKED_EXTENSIONS = %w[exe bat cmd sh php js html htm asp aspx jsp].freeze
-
DEFAULT_ALLOWED_MIME_TYPES = %w[
-
image/jpeg image/png image/gif image/webp
-
application/pdf
-
text/plain text/csv
-
application/msword application/vnd.openxmlformats-officedocument.wordprocessingml.document
-
application/vnd.ms-excel application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
-
application/vnd.ms-powerpoint application/vnd.openxmlformats-officedocument.presentationml.presentation
-
application/zip
-
].freeze
-
DEFAULT_BLOCKED_MIME_TYPES = %w[
-
application/x-executable application/x-msdownload
-
application/x-sh application/x-bat
-
text/html text/javascript
-
application/x-php application/x-asp
-
].freeze
-
-
# Class methods
-
def self.current
-
find_by(tenant: ActsAsTenant.current_tenant) || create_default!
-
end
-
-
def self.create_default!
-
create!(
-
max_file_size: 10.megabytes,
-
allowed_extensions: DEFAULT_ALLOWED_EXTENSIONS,
-
blocked_extensions: DEFAULT_BLOCKED_EXTENSIONS,
-
allowed_mime_types: DEFAULT_ALLOWED_MIME_TYPES,
-
blocked_mime_types: DEFAULT_BLOCKED_MIME_TYPES,
-
scan_for_viruses: false,
-
quarantine_suspicious: true,
-
auto_approve_trusted: false,
-
tenant: ActsAsTenant.current_tenant
-
)
-
end
-
-
# Instance methods
-
def max_file_size_human
-
ActiveSupport::NumberHelper.number_to_human_size(max_file_size)
-
end
-
-
def max_file_size_human=(value)
-
self.max_file_size = parse_file_size(value)
-
end
-
-
def allowed_extensions_list
-
Array(allowed_extensions).join(', ')
-
end
-
-
def allowed_extensions_list=(value)
-
self.allowed_extensions = value.split(',').map(&:strip).map(&:downcase).reject(&:blank?)
-
end
-
-
def blocked_extensions_list
-
Array(blocked_extensions).join(', ')
-
end
-
-
def blocked_extensions_list=(value)
-
self.blocked_extensions = value.split(',').map(&:strip).map(&:downcase).reject(&:blank?)
-
end
-
-
def allowed_mime_types_list
-
Array(allowed_mime_types).join(', ')
-
end
-
-
def allowed_mime_types_list=(value)
-
self.allowed_mime_types = value.split(',').map(&:strip).reject(&:blank?)
-
end
-
-
def blocked_mime_types_list
-
Array(blocked_mime_types).join(', ')
-
end
-
-
def blocked_mime_types_list=(value)
-
self.blocked_mime_types = value.split(',').map(&:strip).reject(&:blank?)
-
end
-
-
# Security validation methods
-
def file_allowed?(file)
-
return false if file.nil?
-
-
# Get storage settings for validation
-
storage_settings = get_storage_settings
-
-
# Check file size against storage settings first, then fallback to upload security
-
max_size_from_storage = storage_settings[:max_file_size] * 1024 * 1024 # Convert MB to bytes
-
effective_max_size = [max_file_size, max_size_from_storage].min
-
return false if file.size > effective_max_size
-
-
# Get file extension
-
extension = File.extname(file.original_filename).downcase.gsub('.', '')
-
-
# Check against storage settings allowed file types
-
if storage_settings[:allowed_file_types].present?
-
allowed_types = storage_settings[:allowed_file_types].split(',').map(&:strip).map(&:downcase)
-
return false unless allowed_types.include?(extension)
-
end
-
-
# Check blocked extensions first (more restrictive)
-
return false if Array(blocked_extensions).include?(extension)
-
-
# Check allowed extensions if specified
-
if allowed_extensions.present?
-
return false unless Array(allowed_extensions).include?(extension)
-
end
-
-
# Check MIME type if available
-
if file.content_type.present?
-
# Check blocked MIME types first
-
return false if Array(blocked_mime_types).include?(file.content_type)
-
-
# Check allowed MIME types if specified
-
if allowed_mime_types.present?
-
return false unless Array(allowed_mime_types).include?(file.content_type)
-
end
-
end
-
-
true
-
end
-
-
def file_suspicious?(file)
-
return false unless quarantine_suspicious?
-
-
# Check for suspicious patterns
-
filename = file.original_filename.downcase
-
-
# Double extensions (e.g., file.jpg.exe)
-
return true if filename.match?(/\..*\..*\./)
-
-
# Executable extensions disguised as images
-
suspicious_patterns = [
-
/\.(jpg|jpeg|png|gif)\.(exe|bat|cmd|sh)$/,
-
/\.(pdf|doc)\.(exe|bat|cmd|sh)$/,
-
/\.(zip|rar)\.(exe|bat|cmd|sh)$/
-
]
-
-
return true if suspicious_patterns.any? { |pattern| filename.match?(pattern) }
-
-
false
-
end
-
-
# Get current storage settings
-
def get_storage_settings
-
{
-
max_file_size: SiteSetting.get('max_file_size', 10).to_i,
-
allowed_file_types: SiteSetting.get('allowed_file_types', 'jpg,jpeg,png,gif,pdf,doc,docx,mp4,mp3'),
-
storage_type: SiteSetting.get('storage_type', 'local'),
-
local_storage_path: SiteSetting.get('local_storage_path', Rails.root.join('storage').to_s),
-
enable_cdn: SiteSetting.get('enable_cdn', false),
-
cdn_url: SiteSetting.get('cdn_url', ''),
-
auto_optimize_uploads: SiteSetting.get('auto_optimize_uploads', true)
-
}
-
end
-
-
private
-
-
def set_defaults
-
self.max_file_size ||= 10.megabytes
-
self.allowed_extensions ||= DEFAULT_ALLOWED_EXTENSIONS
-
self.blocked_extensions ||= DEFAULT_BLOCKED_EXTENSIONS
-
self.allowed_mime_types ||= DEFAULT_ALLOWED_MIME_TYPES
-
self.blocked_mime_types ||= DEFAULT_BLOCKED_MIME_TYPES
-
self.scan_for_viruses = false if scan_for_viruses.nil?
-
self.quarantine_suspicious = true if quarantine_suspicious.nil?
-
self.auto_approve_trusted = false if auto_approve_trusted.nil?
-
end
-
-
def parse_file_size(value)
-
case value.to_s.downcase
-
when /(\d+)\s*mb?/
-
$1.to_i.megabytes
-
when /(\d+)\s*gb?/
-
$1.to_i.gigabytes
-
when /(\d+)\s*kb?/
-
$1.to_i.kilobytes
-
when /(\d+)\s*b?/
-
$1.to_i.bytes
-
else
-
value.to_i
-
end
-
end
-
-
def update_global_settings
-
# Update global upload security settings
-
Rails.application.config.upload_security = {
-
max_file_size: max_file_size,
-
allowed_extensions: allowed_extensions,
-
blocked_extensions: blocked_extensions,
-
allowed_mime_types: allowed_mime_types,
-
blocked_mime_types: blocked_mime_types,
-
scan_for_viruses: scan_for_viruses,
-
quarantine_suspicious: quarantine_suspicious,
-
auto_approve_trusted: auto_approve_trusted
-
}
-
end
-
end
-
1
class User < ApplicationRecord
-
# Include default devise modules. Others available are:
-
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
-
1
devise :database_authenticatable, :registerable,
-
:recoverable, :rememberable, :validatable, :omniauthable,
-
omniauth_providers: [:google_oauth2, :github, :facebook, :twitter]
-
-
# WordPress-like roles
-
1
enum role: {
-
subscriber: 0,
-
contributor: 1,
-
author: 2,
-
editor: 3,
-
administrator: 4
-
}
-
-
# Associations
-
# ActiveStorage for avatar
-
1
has_one_attached :avatar
-
-
# Multi-tenancy - users belong to tenants (many-to-one)
-
1
belongs_to :tenant, optional: true
-
-
1
has_many :posts, dependent: :destroy
-
1
has_many :pages, dependent: :destroy
-
1
has_many :media, dependent: :destroy
-
1
has_many :comments, dependent: :destroy
-
1
has_many :api_tokens, dependent: :destroy
-
1
has_many :ai_usages, dependent: :destroy
-
1
has_many :oauth_accounts, dependent: :destroy
-
-
# Meta fields for plugin extensibility
-
1
has_many :meta_fields, as: :metable, dependent: :destroy
-
1
include Metable
-
-
# Editor preference
-
1
EDITOR_OPTIONS = %w[blocknote trix ckeditor editorjs].freeze
-
1
validates :editor_preference, inclusion: { in: EDITOR_OPTIONS }, allow_nil: true
-
-
1
def preferred_editor
-
editor_preference.presence || 'blocknote' # Default to BlockNote
-
end
-
-
# Monaco Editor theme preference
-
1
MONACO_THEMES = %w[auto dark light blue].freeze
-
1
validates :monaco_theme, inclusion: { in: MONACO_THEMES }, allow_nil: true
-
-
# API Key
-
1
validates :api_key, uniqueness: true, allow_nil: true
-
-
1
def preferred_monaco_theme
-
monaco_theme.presence || 'auto' # Default to auto
-
end
-
-
# Sidebar order preference
-
1
def sidebar_order
-
then: 0
if super.present?
-
JSON.parse(super)
-
else: 0
else
-
['publish', 'featured-image', 'categories-tags', 'excerpt', 'seo']
-
end
-
rescue JSON::ParserError
-
['publish', 'featured-image', 'categories-tags', 'excerpt', 'seo']
-
end
-
-
1
def sidebar_order=(order)
-
then: 0
else: 0
super(order.is_a?(Array) ? order.to_json : order)
-
end
-
-
# Validations
-
1
validates :role, presence: true
-
-
# Callbacks
-
1
after_initialize :set_default_role, if: :new_record?
-
1
before_create :generate_api_token
-
-
# Role helper methods
-
1
def admin?
-
administrator?
-
end
-
-
1
def can_publish?
-
author? || editor? || administrator?
-
end
-
-
1
def can_edit_others_posts?
-
editor? || administrator?
-
end
-
-
1
def can_delete_posts?
-
administrator?
-
end
-
-
# API methods
-
1
def regenerate_api_token!
-
update(api_token: generate_token)
-
end
-
-
1
def rate_limit_exceeded?
-
else: 0
then: 0
return false unless api_requests_reset_at
-
-
then: 0
else: 0
if api_requests_reset_at < Time.current
-
update(api_requests_count: 0, api_requests_reset_at: 1.hour.from_now)
-
return false
-
end
-
-
(api_requests_count || 0) >= 1000 # 1000 requests per hour
-
end
-
-
1
def increment_api_request!
-
then: 0
else: 0
self.api_requests_reset_at = 1.hour.from_now if api_requests_reset_at.nil? || api_requests_reset_at < Time.current
-
increment!(:api_requests_count)
-
end
-
-
# Admin bar permission checks
-
1
def can_manage_plugins?
-
administrator? || role == 'editor'
-
end
-
-
1
def can_manage_themes?
-
administrator?
-
end
-
-
1
def can_manage_settings?
-
administrator?
-
end
-
-
1
def can_manage_users?
-
administrator?
-
end
-
-
1
def can_create_posts?
-
['administrator', 'editor', 'author'].include?(role)
-
end
-
-
1
def can_create_pages?
-
['administrator', 'editor'].include?(role)
-
end
-
-
1
def can_upload_media?
-
['administrator', 'editor', 'author'].include?(role)
-
end
-
-
1
def can_upload_files?
-
['administrator', 'editor', 'author'].include?(role)
-
end
-
-
# API Key methods
-
1
def generate_api_key
-
loop do
-
key = "sk-#{SecureRandom.hex(32)}"
-
else: 0
then: 0
break key unless User.exists?(api_key: key)
-
end
-
end
-
-
1
def regenerate_api_key!
-
self.api_key = generate_api_key
-
save!
-
end
-
-
1
private
-
-
1
def set_default_role
-
self.role ||= :subscriber
-
end
-
-
1
def generate_api_token
-
self.api_token = generate_token
-
self.api_key = generate_api_key
-
self.api_requests_count = 0
-
self.api_requests_reset_at = 1.hour.from_now
-
end
-
-
1
def generate_token
-
loop do
-
token = SecureRandom.hex(32)
-
else: 0
then: 0
break token unless User.exists?(api_token: token)
-
end
-
end
-
-
1
def create_user_tenant
-
# Create a tenant for this user if they don't have one
-
then: 0
else: 0
return if tenant_id.present?
-
-
# Generate a unique subdomain based on email
-
base_subdomain = email.split('@').first.gsub(/[^a-z0-9]/, '')
-
subdomain = base_subdomain
-
counter = 1
-
-
# Ensure subdomain is unique
-
body: 0
while Tenant.exists?(subdomain: subdomain)
-
subdomain = "#{base_subdomain}#{counter}"
-
counter += 1
-
end
-
-
# Create the tenant
-
user_tenant = Tenant.create!(
-
name: "#{email.split('@').first.humanize}'s Site",
-
subdomain: subdomain,
-
domain: nil, # Will be set later if needed
-
theme: 'nordic',
-
storage_type: 'local'
-
)
-
-
self.tenant = user_tenant
-
end
-
end
-
class UserConsent < ApplicationRecord
-
acts_as_tenant(:tenant)
-
-
belongs_to :user
-
-
validates :consent_type, presence: true, uniqueness: { scope: :user_id }
-
validates :consent_text, presence: true
-
validates :ip_address, presence: true
-
validates :user_agent, presence: true
-
-
# Consent types
-
CONSENT_TYPES = %w[
-
data_processing
-
marketing
-
analytics
-
cookies
-
newsletter
-
third_party_sharing
-
].freeze
-
-
validates :consent_type, inclusion: { in: CONSENT_TYPES }
-
-
scope :granted, -> { where(granted: true) }
-
scope :withdrawn, -> { where(granted: false) }
-
scope :by_type, ->(type) { where(consent_type: type) }
-
scope :recent, -> { order(updated_at: :desc) }
-
-
# Callbacks
-
before_validation :set_defaults, on: :create
-
-
def granted?
-
granted && granted_at.present? && withdrawn_at.nil?
-
end
-
-
def withdrawn?
-
!granted || withdrawn_at.present?
-
end
-
-
def withdraw!
-
update!(
-
granted: false,
-
withdrawn_at: Time.current
-
)
-
end
-
-
def grant!
-
update!(
-
granted: true,
-
granted_at: Time.current,
-
withdrawn_at: nil
-
)
-
end
-
-
private
-
-
def set_defaults
-
self.granted_at ||= Time.current if granted
-
end
-
end
-
class UserNotification < ApplicationRecord
-
belongs_to :user
-
end
-
class Webhook < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Associations
-
has_many :webhook_deliveries, dependent: :destroy
-
-
# Serialization
-
serialize :events, coder: JSON, type: Array
-
-
# Validations
-
validates :url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) }
-
validates :secret_key, presence: true
-
validates :name, presence: true
-
validates :events, presence: true
-
validates :retry_limit, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }
-
validates :timeout, numericality: { greater_than: 0, less_than_or_equal_to: 120 }
-
-
# Callbacks
-
before_validation :generate_secret_key, on: :create
-
before_validation :set_defaults
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :for_event, ->(event_type) { where("events LIKE ?", "%#{event_type}%") }
-
-
# Available webhook events
-
AVAILABLE_EVENTS = [
-
'post.created',
-
'post.updated',
-
'post.published',
-
'post.deleted',
-
'page.created',
-
'page.updated',
-
'page.published',
-
'page.deleted',
-
'comment.created',
-
'comment.approved',
-
'comment.spam',
-
'user.created',
-
'user.updated',
-
'media.uploaded'
-
].freeze
-
-
# Check if webhook is subscribed to an event
-
def subscribed_to?(event_type)
-
events.include?(event_type)
-
end
-
-
# Deliver a webhook
-
def deliver(event_type, payload)
-
return unless active? && subscribed_to?(event_type)
-
-
delivery = webhook_deliveries.create!(
-
event_type: event_type,
-
payload: payload,
-
status: 'pending',
-
request_id: SecureRandom.uuid
-
)
-
-
# Enqueue for delivery
-
DeliverWebhookJob.perform_later(delivery.id)
-
-
delivery
-
end
-
-
# Generate HMAC signature for payload
-
def sign_payload(payload_json)
-
OpenSSL::HMAC.hexdigest('SHA256', secret_key, payload_json)
-
end
-
-
# Update delivery statistics
-
def record_delivery(success:)
-
increment!(:total_deliveries)
-
increment!(:failed_deliveries) unless success
-
touch(:last_delivered_at) if success
-
end
-
-
# Check if webhook is healthy
-
def healthy?
-
return true if total_deliveries.zero?
-
-
failure_rate = failed_deliveries.to_f / total_deliveries
-
failure_rate < 0.5 # Less than 50% failure rate
-
end
-
-
# Calculate success rate percentage
-
def success_rate
-
return 100.0 if total_deliveries.zero?
-
-
successful_deliveries = total_deliveries - failed_deliveries
-
(successful_deliveries.to_f / total_deliveries * 100).round(1)
-
end
-
-
private
-
-
def generate_secret_key
-
self.secret_key ||= SecureRandom.hex(32)
-
end
-
-
def set_defaults
-
self.retry_limit ||= 3
-
self.timeout ||= 30
-
self.total_deliveries ||= 0
-
self.failed_deliveries ||= 0
-
end
-
end
-
class WebhookDelivery < ApplicationRecord
-
# Associations
-
belongs_to :webhook
-
-
# Validations
-
validates :event_type, presence: true
-
validates :status, presence: true, inclusion: { in: %w[pending success failed] }
-
-
# Enums
-
enum status: {
-
pending: 'pending',
-
success: 'success',
-
failed: 'failed'
-
}, _suffix: true
-
-
# Scopes
-
scope :recent, -> { order(created_at: :desc) }
-
scope :failed, -> { where(status: 'failed') }
-
scope :successful, -> { where(status: 'success') }
-
scope :pending_retry, -> { where('status = ? AND retry_count < ? AND next_retry_at <= ?', 'failed', 3, Time.current) }
-
-
# Callbacks
-
after_create :schedule_delivery
-
-
# Check if delivery can be retried
-
def can_retry?
-
failed_status? && retry_count < webhook.retry_limit
-
end
-
-
# Mark as successful
-
def mark_success!(response_code, response_body)
-
update!(
-
status: 'success',
-
response_code: response_code,
-
response_body: response_body.to_s.truncate(5000),
-
delivered_at: Time.current
-
)
-
-
webhook.record_delivery(success: true)
-
end
-
-
# Mark as failed
-
def mark_failed!(error_message, response_code = nil, response_body = nil)
-
update!(
-
status: 'failed',
-
error_message: error_message.to_s.truncate(1000),
-
response_code: response_code,
-
response_body: response_body.to_s.truncate(5000)
-
)
-
-
webhook.record_delivery(success: false)
-
-
# Schedule retry if allowed
-
schedule_retry if can_retry?
-
end
-
-
# Schedule retry with exponential backoff
-
def schedule_retry
-
increment!(:retry_count)
-
-
# Exponential backoff: 1min, 5min, 15min
-
delay = case retry_count
-
when 1 then 1.minute
-
when 2 then 5.minutes
-
else 15.minutes
-
end
-
-
update!(next_retry_at: delay.from_now)
-
-
# Schedule the retry job
-
DeliverWebhookJob.set(wait: delay).perform_later(id)
-
end
-
-
# Get signed headers for delivery
-
def signed_headers
-
payload_json = payload.to_json
-
signature = webhook.sign_payload(payload_json)
-
-
{
-
'Content-Type' => 'application/json',
-
'User-Agent' => 'RailsPress-Webhooks/1.0',
-
'X-RailsPress-Event' => event_type,
-
'X-RailsPress-Delivery' => request_id,
-
'X-RailsPress-Signature' => signature,
-
'X-RailsPress-Signature-256' => "sha256=#{signature}"
-
}
-
end
-
-
# Check if delivery was successful
-
def success_status?
-
success?
-
end
-
-
private
-
-
def schedule_delivery
-
DeliverWebhookJob.perform_later(id) if pending_status?
-
end
-
end
-
class Widget < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant)
-
-
# Serialization
-
serialize :settings, coder: JSON, type: Hash
-
-
# Validations
-
validates :title, presence: true
-
validates :widget_type, presence: true
-
validates :sidebar_location, presence: true
-
validates :position, presence: true, numericality: { only_integer: true }
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :by_location, ->(location) { where(sidebar_location: location) }
-
scope :ordered, -> { order(position: :asc) }
-
-
# Callbacks
-
after_initialize :set_defaults, if: :new_record?
-
-
# Widget types
-
WIDGET_TYPES = %w[
-
text
-
recent_posts
-
categories
-
tags
-
search
-
custom_html
-
recent_comments
-
archives
-
].freeze
-
-
validates :widget_type, inclusion: { in: WIDGET_TYPES }
-
-
private
-
-
def set_defaults
-
self.active = true if active.nil?
-
self.settings ||= {}
-
self.position ||= (Widget.where(sidebar_location: sidebar_location).maximum(:position) || 0) + 1
-
end
-
end
-
# frozen_string_literal: true
-
-
class ApplicationPolicy
-
attr_reader :user, :record
-
-
def initialize(user, record)
-
@user = user
-
@record = record
-
end
-
-
def index?
-
false
-
end
-
-
def show?
-
false
-
end
-
-
def create?
-
false
-
end
-
-
def new?
-
create?
-
end
-
-
def update?
-
false
-
end
-
-
def edit?
-
update?
-
end
-
-
def destroy?
-
false
-
end
-
-
class Scope
-
def initialize(user, scope)
-
@user = user
-
@scope = scope
-
end
-
-
def resolve
-
raise NoMethodError, "You must define #resolve in #{self.class}"
-
end
-
-
private
-
-
attr_reader :user, :scope
-
end
-
end
-
# frozen_string_literal: true
-
-
class AdvancedAnalyticsService
-
include Rails.application.routes.url_helpers
-
-
# Advanced tracking capabilities like GA4/Matomo
-
class << self
-
# Track user journey and behavior patterns
-
def track_user_journey(session_id, user_id = nil, event_data = {})
-
return unless analytics_enabled?
-
-
journey_data = {
-
session_id: session_id,
-
user_id: user_id,
-
timestamp: Time.current,
-
events: [],
-
metadata: {}
-
}.merge(event_data)
-
-
# Store in Redis for real-time analysis
-
Redis.current.setex("user_journey:#{session_id}", 30.minutes.to_i, journey_data.to_json)
-
-
# Queue for background processing
-
AnalyticsProcessingJob.perform_later('user_journey', journey_data)
-
end
-
-
# Track conversion funnels with attribution
-
def track_conversion_funnel(funnel_id, step, session_id, user_id = nil, properties = {})
-
return unless analytics_enabled?
-
-
funnel_data = {
-
funnel_id: funnel_id,
-
step: step,
-
session_id: session_id,
-
user_id: user_id,
-
timestamp: Time.current,
-
properties: properties,
-
attribution: get_attribution_data(session_id)
-
}
-
-
# Store conversion event
-
AnalyticsEvent.track_conversion(
-
event_name: "funnel_#{funnel_id}_#{step}",
-
session_id: session_id,
-
user_id: user_id,
-
properties: funnel_data
-
)
-
-
# Update funnel progress
-
update_funnel_progress(funnel_id, step, session_id)
-
end
-
-
# Track cohort analysis
-
def track_user_cohort(user_id, cohort_type, properties = {})
-
return unless analytics_enabled? && user_id.present?
-
-
cohort_data = {
-
user_id: user_id,
-
cohort_type: cohort_type,
-
timestamp: Time.current,
-
properties: properties
-
}
-
-
# Store cohort membership
-
Redis.current.sadd("cohort:#{cohort_type}:#{Date.current.strftime('%Y-%m')}", user_id)
-
Redis.current.hset("user_cohort:#{user_id}", cohort_type, cohort_data.to_json)
-
end
-
-
# Track attribution and multi-touch attribution
-
def track_attribution(session_id, touchpoint_type, touchpoint_data = {})
-
return unless analytics_enabled?
-
-
attribution_data = {
-
session_id: session_id,
-
touchpoint_type: touchpoint_type,
-
timestamp: Time.current,
-
data: touchpoint_data
-
}
-
-
# Store attribution chain
-
Redis.current.lpush("attribution:#{session_id}", attribution_data.to_json)
-
Redis.current.expire("attribution:#{session_id}", 30.days.to_i)
-
end
-
-
# Track custom dimensions and metrics
-
def track_custom_dimension(session_id, dimension_name, dimension_value)
-
return unless analytics_enabled?
-
-
dimension_data = {
-
session_id: session_id,
-
dimension_name: dimension_name,
-
dimension_value: dimension_value,
-
timestamp: Time.current
-
}
-
-
# Store custom dimension
-
Redis.current.hset("custom_dimensions:#{session_id}", dimension_name, dimension_value)
-
Redis.current.expire("custom_dimensions:#{session_id}", 30.days.to_i)
-
-
# Track as event
-
AnalyticsEvent.track_conversion(
-
event_name: 'custom_dimension',
-
session_id: session_id,
-
properties: dimension_data
-
)
-
end
-
-
# Track advanced e-commerce events (for future WooCommerce-like plugins)
-
def track_ecommerce_event(event_type, session_id, user_id = nil, ecommerce_data = {})
-
return unless analytics_enabled?
-
-
ecommerce_event = {
-
event_type: event_type,
-
session_id: session_id,
-
user_id: user_id,
-
timestamp: Time.current,
-
ecommerce_data: ecommerce_data
-
}
-
-
# Track e-commerce event
-
AnalyticsEvent.track_conversion(
-
event_name: "ecommerce_#{event_type}",
-
session_id: session_id,
-
user_id: user_id,
-
properties: ecommerce_event
-
)
-
end
-
-
# Track content engagement with advanced metrics
-
def track_content_engagement(content_id, content_type, engagement_data = {})
-
return unless analytics_enabled?
-
-
engagement_metrics = {
-
content_id: content_id,
-
content_type: content_type,
-
timestamp: Time.current,
-
scroll_depth: engagement_data[:scroll_depth] || 0,
-
reading_time: engagement_data[:reading_time] || 0,
-
interaction_events: engagement_data[:interactions] || [],
-
exit_intent: engagement_data[:exit_intent] || false,
-
video_engagement: engagement_data[:video_engagement] || {},
-
form_interactions: engagement_data[:form_interactions] || []
-
}
-
-
# Store engagement data
-
Redis.current.hset("content_engagement:#{content_id}",
-
Time.current.to_i,
-
engagement_metrics.to_json)
-
-
# Update content analytics
-
update_content_analytics(content_id, content_type, engagement_metrics)
-
end
-
-
# Track A/B test performance
-
def track_ab_test(test_id, variant, session_id, user_id = nil, conversion_data = {})
-
return unless analytics_enabled?
-
-
ab_test_data = {
-
test_id: test_id,
-
variant: variant,
-
session_id: session_id,
-
user_id: user_id,
-
timestamp: Time.current,
-
conversion_data: conversion_data
-
}
-
-
# Store A/B test data
-
Redis.current.sadd("ab_test:#{test_id}:variant:#{variant}", session_id)
-
Redis.current.hset("ab_test_session:#{session_id}", test_id, variant)
-
-
# Track as event
-
AnalyticsEvent.track_conversion(
-
event_name: "ab_test_#{test_id}",
-
session_id: session_id,
-
user_id: user_id,
-
properties: ab_test_data
-
)
-
end
-
-
# Track user lifetime value and RFM analysis
-
def track_user_lifetime_value(user_id, transaction_data = {})
-
return unless analytics_enabled? && user_id.present?
-
-
ltv_data = {
-
user_id: user_id,
-
timestamp: Time.current,
-
transaction_value: transaction_data[:value] || 0,
-
transaction_count: transaction_data[:count] || 1,
-
last_transaction: transaction_data[:last_transaction] || Time.current
-
}
-
-
# Update user LTV
-
Redis.current.hincrby("user_ltv:#{user_id}", "total_value", ltv_data[:transaction_value])
-
Redis.current.hincrby("user_ltv:#{user_id}", "transaction_count", ltv_data[:transaction_count])
-
Redis.current.hset("user_ltv:#{user_id}", "last_transaction", ltv_data[:last_transaction].to_i)
-
end
-
-
# Track predictive analytics data
-
def track_predictive_data(session_id, user_id = nil, predictive_features = {})
-
return unless analytics_enabled?
-
-
predictive_data = {
-
session_id: session_id,
-
user_id: user_id,
-
timestamp: Time.current,
-
features: predictive_features
-
}
-
-
# Store for ML model training
-
Redis.current.lpush("predictive_features:#{user_id || session_id}", predictive_data.to_json)
-
Redis.current.expire("predictive_features:#{user_id || session_id}", 90.days.to_i)
-
end
-
-
# Get comprehensive user profile
-
def get_user_profile(user_id)
-
return {} unless user_id.present?
-
-
profile_data = {
-
demographics: get_user_demographics(user_id),
-
behavior: get_user_behavior(user_id),
-
preferences: get_user_preferences(user_id),
-
lifetime_value: get_user_ltv(user_id),
-
cohort_data: get_user_cohorts(user_id),
-
attribution: get_user_attribution(user_id)
-
}
-
-
profile_data
-
end
-
-
# Generate advanced reports
-
def generate_advanced_report(report_type, params = {})
-
case report_type.to_s
-
when 'attribution'
-
generate_attribution_report(params)
-
when 'cohort'
-
generate_cohort_report(params)
-
when 'funnel'
-
generate_funnel_report(params)
-
when 'rfm'
-
generate_rfm_report(params)
-
when 'predictive'
-
generate_predictive_report(params)
-
else
-
generate_custom_report(report_type, params)
-
end
-
end
-
-
private
-
-
def analytics_enabled?
-
SiteSetting.get('analytics_enabled', true)
-
end
-
-
def get_attribution_data(session_id)
-
attribution_chain = Redis.current.lrange("attribution:#{session_id}", 0, -1)
-
attribution_chain.map { |data| JSON.parse(data) rescue nil }.compact
-
end
-
-
def update_funnel_progress(funnel_id, step, session_id)
-
Redis.current.hset("funnel_progress:#{funnel_id}", session_id, {
-
current_step: step,
-
timestamp: Time.current
-
}.to_json)
-
end
-
-
def update_content_analytics(content_id, content_type, engagement_data)
-
# Update content analytics in background
-
ContentAnalyticsUpdateJob.perform_later(content_id, content_type, engagement_data)
-
end
-
-
def get_user_demographics(user_id)
-
pageviews = Pageview.where(user_id: user_id).recent(1.year.ago)
-
{
-
countries: pageviews.group(:country_name).count,
-
devices: pageviews.group(:device).count,
-
browsers: pageviews.group(:browser).count,
-
operating_systems: pageviews.group(:operating_system).count
-
}
-
end
-
-
def get_user_behavior(user_id)
-
pageviews = Pageview.where(user_id: user_id).recent(1.year.ago)
-
{
-
avg_session_duration: pageviews.average(:time_on_page) || 0,
-
avg_pages_per_session: pageviews.group(:session_id).count.values.mean || 0,
-
bounce_rate: calculate_user_bounce_rate(user_id),
-
return_visitor: pageviews.distinct.count(:session_id) > 1
-
}
-
end
-
-
def get_user_preferences(user_id)
-
events = AnalyticsEvent.where(user_id: user_id).recent(1.year.ago)
-
{
-
preferred_content_types: events.where(event_name: 'content_view').group(:properties).count,
-
preferred_times: events.group_by_hour(:created_at).count,
-
preferred_devices: events.joins(:pageviews).group('pageviews.device').count
-
}
-
end
-
-
def get_user_ltv(user_id)
-
ltv_data = Redis.current.hgetall("user_ltv:#{user_id}")
-
{
-
total_value: ltv_data['total_value']&.to_f || 0,
-
transaction_count: ltv_data['transaction_count']&.to_i || 0,
-
last_transaction: ltv_data['last_transaction']&.to_i
-
}
-
end
-
-
def get_user_cohorts(user_id)
-
cohort_data = Redis.current.hgetall("user_cohort:#{user_id}")
-
cohort_data.transform_values { |data| JSON.parse(data) rescue nil }
-
end
-
-
def get_user_attribution(user_id)
-
# Get attribution data for user's sessions
-
user_sessions = Pageview.where(user_id: user_id).distinct.pluck(:session_id)
-
user_sessions.map { |session_id| get_attribution_data(session_id) }.flatten
-
end
-
-
def calculate_user_bounce_rate(user_id)
-
user_sessions = Pageview.where(user_id: user_id).group(:session_id)
-
single_page_sessions = user_sessions.having('COUNT(*) = 1').count
-
total_sessions = user_sessions.count
-
-
return 0 if total_sessions.zero?
-
(single_page_sessions.to_f / total_sessions * 100).round(2)
-
end
-
-
def generate_attribution_report(params)
-
# Multi-touch attribution analysis
-
{
-
first_touch: get_first_touch_attribution(params),
-
last_touch: get_last_touch_attribution(params),
-
linear: get_linear_attribution(params),
-
time_decay: get_time_decay_attribution(params)
-
}
-
end
-
-
def generate_cohort_report(params)
-
# Cohort analysis by month/week
-
cohorts = {}
-
(0..12).each do |i|
-
period = i.months.ago.strftime('%Y-%m')
-
cohorts[period] = get_cohort_data(period, params)
-
end
-
cohorts
-
end
-
-
def generate_funnel_report(params)
-
# Conversion funnel analysis
-
funnel_id = params[:funnel_id]
-
steps = Redis.current.hgetall("funnel_progress:#{funnel_id}")
-
-
{
-
funnel_id: funnel_id,
-
steps: steps,
-
conversion_rates: calculate_funnel_conversion_rates(funnel_id),
-
drop_off_points: identify_drop_off_points(funnel_id)
-
}
-
end
-
-
def generate_rfm_report(params)
-
# Recency, Frequency, Monetary analysis
-
users = User.joins(:pageviews).distinct
-
-
{
-
recency: calculate_recency_segments(users),
-
frequency: calculate_frequency_segments(users),
-
monetary: calculate_monetary_segments(users),
-
rfm_matrix: generate_rfm_matrix(users)
-
}
-
end
-
-
def generate_predictive_report(params)
-
# Predictive analytics based on ML features
-
{
-
churn_prediction: predict_user_churn(params),
-
lifetime_value_prediction: predict_ltv(params),
-
next_purchase_prediction: predict_next_purchase(params),
-
content_recommendation: recommend_content(params)
-
}
-
end
-
-
def generate_custom_report(report_type, params)
-
# Custom report generation
-
{
-
report_type: report_type,
-
params: params,
-
data: generate_custom_data(report_type, params),
-
generated_at: Time.current
-
}
-
end
-
-
# Helper methods for report generation
-
def get_first_touch_attribution(params)
-
# Implementation for first-touch attribution
-
{}
-
end
-
-
def get_last_touch_attribution(params)
-
# Implementation for last-touch attribution
-
{}
-
end
-
-
def get_linear_attribution(params)
-
# Implementation for linear attribution
-
{}
-
end
-
-
def get_time_decay_attribution(params)
-
# Implementation for time-decay attribution
-
{}
-
end
-
-
def get_cohort_data(period, params)
-
# Implementation for cohort data
-
{}
-
end
-
-
def calculate_funnel_conversion_rates(funnel_id)
-
# Implementation for funnel conversion rates
-
{}
-
end
-
-
def identify_drop_off_points(funnel_id)
-
# Implementation for drop-off analysis
-
{}
-
end
-
-
def calculate_recency_segments(users)
-
# Implementation for recency segments
-
{}
-
end
-
-
def calculate_frequency_segments(users)
-
# Implementation for frequency segments
-
{}
-
end
-
-
def calculate_monetary_segments(users)
-
# Implementation for monetary segments
-
{}
-
end
-
-
def generate_rfm_matrix(users)
-
# Implementation for RFM matrix
-
{}
-
end
-
-
def predict_user_churn(params)
-
# Implementation for churn prediction
-
{}
-
end
-
-
def predict_ltv(params)
-
# Implementation for LTV prediction
-
{}
-
end
-
-
def predict_next_purchase(params)
-
# Implementation for next purchase prediction
-
{}
-
end
-
-
def recommend_content(params)
-
# Implementation for content recommendation
-
{}
-
end
-
-
def generate_custom_data(report_type, params)
-
# Implementation for custom data generation
-
{}
-
end
-
end
-
end
-
class AiHelper
-
class << self
-
# Execute an AI agent by type
-
def execute_agent(agent_type, user_input = "", context = {})
-
agent = AiAgent.active.find_by(agent_type: agent_type)
-
return { success: false, error: "No active agent found for type: #{agent_type}" } unless agent
-
-
begin
-
result = agent.execute(user_input, context)
-
{ success: true, result: result, agent: agent }
-
rescue => e
-
{ success: false, error: e.message }
-
end
-
end
-
-
# Generate content using the Post Writer agent
-
def generate_post_content(topic, tone = "professional", additional_context = {})
-
context = {
-
tone: tone,
-
target_audience: additional_context[:target_audience] || "general audience",
-
word_count: additional_context[:word_count] || "800-1200",
-
keywords: additional_context[:keywords] || ""
-
}.merge(additional_context)
-
-
execute_agent('post_writer', topic, context)
-
end
-
-
# Summarize content using the Content Summarizer agent
-
def summarize_content(content, summary_length = "medium")
-
context = {
-
content: content,
-
length: summary_length
-
}
-
-
execute_agent('content_summarizer', content, context)
-
end
-
-
# Analyze comments using the Comments Analyzer agent
-
def analyze_comments(comments)
-
context = {
-
comments: comments
-
}
-
-
execute_agent('comments_analyzer', comments, context)
-
end
-
-
# Analyze SEO using the SEO Analyzer agent
-
def analyze_seo(content, target_keywords = [])
-
context = {
-
content: content,
-
target_keywords: target_keywords.join(', '),
-
url: content[:url] if content.is_a?(Hash),
-
title: content[:title] if content.is_a?(Hash)
-
}
-
-
execute_agent('seo_analyzer', content.is_a?(Hash) ? content[:content] || content[:text] : content, context)
-
end
-
-
# Get available agent types
-
def available_agents
-
AiAgent.active.pluck(:agent_type).uniq
-
end
-
-
# Check if an agent type is available
-
def agent_available?(agent_type)
-
AiAgent.active.exists?(agent_type: agent_type)
-
end
-
-
# Get agent info
-
def agent_info(agent_type)
-
agent = AiAgent.active.find_by(agent_type: agent_type)
-
return nil unless agent
-
-
{
-
id: agent.id,
-
name: agent.name,
-
description: agent.description,
-
provider: agent.ai_provider.name,
-
model: agent.ai_provider.model_identifier
-
}
-
end
-
end
-
end
-
-
-
-
-
-
-
class AiService
-
def initialize(provider)
-
@provider = provider
-
end
-
-
def generate(prompt)
-
case @provider.provider_type
-
when 'openai'
-
call_openai(prompt)
-
when 'cohere'
-
call_cohere(prompt)
-
when 'anthropic'
-
call_anthropic(prompt)
-
when 'google'
-
call_google(prompt)
-
else
-
raise "Unsupported provider type: #{@provider.provider_type}"
-
end
-
end
-
-
private
-
-
def call_openai(prompt)
-
require 'net/http'
-
require 'json'
-
-
uri = URI('https://api.openai.com/v1/chat/completions')
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
-
request = Net::HTTP::Post.new(uri)
-
request['Authorization'] = "Bearer #{@provider.api_key}"
-
request['Content-Type'] = 'application/json'
-
-
body = {
-
model: @provider.model_identifier,
-
messages: [{ role: "user", content: prompt }],
-
max_tokens: @provider.max_tokens,
-
temperature: @provider.temperature
-
}
-
-
request.body = body.to_json
-
response = http.request(request)
-
-
if response.code == '200'
-
parsed_response = JSON.parse(response.body)
-
content = parsed_response.dig('choices', 0, 'message', 'content')
-
raise "Invalid response format: missing content" if content.nil?
-
content
-
else
-
raise "OpenAI API error: #{response.body}"
-
end
-
rescue => e
-
raise e
-
end
-
-
def call_cohere(prompt)
-
require 'net/http'
-
require 'json'
-
-
uri = URI('https://api.cohere.ai/v1/chat')
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
-
request = Net::HTTP::Post.new(uri)
-
request['Authorization'] = "Bearer #{@provider.api_key}"
-
request['Content-Type'] = 'application/json'
-
-
body = {
-
model: @provider.model_identifier,
-
message: prompt,
-
max_tokens: @provider.max_tokens.to_i,
-
temperature: @provider.temperature.to_f,
-
stream: false
-
}
-
-
request.body = body.to_json
-
response = http.request(request)
-
-
if response.code == '200'
-
JSON.parse(response.body)['text']
-
else
-
raise "Cohere API error: #{response.body}"
-
end
-
rescue => e
-
raise e
-
end
-
-
def call_anthropic(prompt)
-
require 'net/http'
-
require 'json'
-
-
uri = URI('https://api.anthropic.com/v1/messages')
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
-
request = Net::HTTP::Post.new(uri)
-
request['x-api-key'] = @provider.api_key
-
request['Content-Type'] = 'application/json'
-
request['anthropic-version'] = '2023-06-01'
-
-
body = {
-
model: @provider.model_identifier,
-
max_tokens: @provider.max_tokens,
-
temperature: @provider.temperature,
-
messages: [{ role: "user", content: prompt }]
-
}
-
-
request.body = body.to_json
-
response = http.request(request)
-
-
if response.code == '200'
-
JSON.parse(response.body)['content'][0]['text']
-
else
-
raise "Anthropic API error: #{response.body}"
-
end
-
rescue => e
-
raise e
-
end
-
-
def call_google(prompt)
-
require 'net/http'
-
require 'json'
-
-
uri = URI("https://generativelanguage.googleapis.com/v1beta/models/#{@provider.model_identifier}:generateContent")
-
uri.query = URI.encode_www_form(key: @provider.api_key)
-
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
-
request = Net::HTTP::Post.new(uri)
-
request['Content-Type'] = 'application/json'
-
-
body = {
-
contents: [{
-
parts: [{ text: prompt }]
-
}],
-
generationConfig: {
-
maxOutputTokens: @provider.max_tokens,
-
temperature: @provider.temperature
-
}
-
}
-
-
request.body = body.to_json
-
response = http.request(request)
-
-
if response.code == '200'
-
JSON.parse(response.body)['candidates'][0]['content']['parts'][0]['text']
-
else
-
raise "Google API error: #{response.body}"
-
end
-
rescue => e
-
raise e
-
end
-
end
-
-
-
-
-
-
-
class AkismetService
-
AKISMET_URL = 'https://rest.akismet.com/1.1'
-
-
def initialize(api_key, site_url)
-
@api_key = api_key
-
@site_url = site_url
-
@blog = site_url
-
end
-
-
# Check if a comment is spam
-
def spam?(comment_data)
-
return false unless @api_key.present?
-
-
begin
-
response = make_request('comment-check', comment_data)
-
Rails.logger.info "Akismet response: #{response}"
-
response.strip == 'true'
-
rescue => e
-
Rails.logger.error "Akismet error: #{e.message}"
-
false # Don't block comments if Akismet fails
-
end
-
end
-
-
# Verify the API key is valid
-
def verify_key
-
return false unless @api_key.present?
-
-
begin
-
response = make_request('verify-key', {
-
key: @api_key,
-
blog: @blog
-
})
-
Rails.logger.info "Akismet key verification: #{response}"
-
response.strip == 'valid'
-
rescue => e
-
Rails.logger.error "Akismet key verification error: #{e.message}"
-
false
-
end
-
end
-
-
# Submit a false positive (ham)
-
def submit_ham(comment_data)
-
return false unless @api_key.present?
-
-
begin
-
response = make_request('submit-ham', comment_data)
-
Rails.logger.info "Akismet submit ham: #{response}"
-
response.strip == 'Thanks for making the web a better place.'
-
rescue => e
-
Rails.logger.error "Akismet submit ham error: #{e.message}"
-
false
-
end
-
end
-
-
# Submit a false negative (spam)
-
def submit_spam(comment_data)
-
return false unless @api_key.present?
-
-
begin
-
response = make_request('submit-spam', comment_data)
-
Rails.logger.info "Akismet submit spam: #{response}"
-
response.strip == 'Thanks for making the web a better place.'
-
rescue => e
-
Rails.logger.error "Akismet submit spam error: #{e.message}"
-
false
-
end
-
end
-
-
private
-
-
def make_request(action, data)
-
uri = URI("#{AKISMET_URL}/#{action}")
-
-
# Add API key to the data
-
request_data = {
-
blog: @blog,
-
key: @api_key
-
}.merge(data)
-
-
http = Net::HTTP.new(uri.host, uri.port)
-
http.use_ssl = true
-
http.read_timeout = 10
-
http.open_timeout = 10
-
-
request = Net::HTTP::Post.new(uri)
-
request.set_form_data(request_data)
-
request['User-Agent'] = "RailsPress/1.0 | Akismet/1.0"
-
-
response = http.request(request)
-
-
if response.code == '200'
-
response.body
-
else
-
raise "Akismet API error: #{response.code} #{response.message}"
-
end
-
end
-
end
-
class AnalyticsArchiveService
-
include Singleton
-
-
def initialize
-
@default_retention_days = SiteSetting.get('analytics_data_retention_days', 365).to_i
-
@archive_enabled = SiteSetting.get('analytics_archive_enabled', true)
-
@export_format = SiteSetting.get('analytics_export_format', 'json') # json, csv, parquet
-
end
-
-
# Archive old analytics data
-
def archive_old_data
-
return unless @archive_enabled
-
-
cutoff_date = @default_retention_days.days.ago
-
-
Rails.logger.info "Starting analytics data archival for data older than #{cutoff_date}"
-
-
archived_count = 0
-
-
# Archive old pageviews
-
archived_count += archive_pageviews(cutoff_date)
-
-
# Archive old analytics events
-
archived_count += archive_analytics_events(cutoff_date)
-
-
Rails.logger.info "Archived #{archived_count} analytics records"
-
-
# Clean up archived data if configured
-
if SiteSetting.get('analytics_auto_delete_archived', false)
-
cleanup_archived_data
-
end
-
-
archived_count
-
end
-
-
# Export analytics data for specific date range
-
def export_data(start_date, end_date, format: @export_format, include_events: true)
-
Rails.logger.info "Exporting analytics data from #{start_date} to #{end_date} in #{format} format"
-
-
export_data = {
-
metadata: {
-
export_date: Time.current,
-
date_range: { start: start_date, end: end_date },
-
format: format,
-
version: '1.0'
-
},
-
pageviews: export_pageviews(start_date, end_date),
-
events: include_events ? export_analytics_events(start_date, end_date) : []
-
}
-
-
case format
-
when 'json'
-
export_data.to_json
-
when 'csv'
-
convert_to_csv(export_data)
-
when 'parquet'
-
convert_to_parquet(export_data)
-
else
-
export_data.to_json
-
end
-
end
-
-
# Get archive statistics
-
def archive_stats
-
{
-
total_pageviews: Pageview.count,
-
total_events: AnalyticsEvent.count,
-
archived_pageviews: ArchivedPageview.count,
-
archived_events: ArchivedAnalyticsEvent.count,
-
oldest_data: oldest_data_date,
-
retention_policy: {
-
days: @default_retention_days,
-
enabled: @archive_enabled,
-
auto_delete: SiteSetting.get('analytics_auto_delete_archived', false)
-
}
-
}
-
end
-
-
# Schedule automatic archiving
-
def schedule_auto_archive
-
return unless @archive_enabled
-
-
frequency = SiteSetting.get('analytics_archive_frequency', 'daily') # daily, weekly, monthly
-
-
case frequency
-
when 'daily'
-
AnalyticsArchiveJob.perform_in(1.day)
-
when 'weekly'
-
AnalyticsArchiveJob.perform_in(1.week)
-
when 'monthly'
-
AnalyticsArchiveJob.perform_in(1.month)
-
end
-
end
-
-
private
-
-
def archive_pageviews(cutoff_date)
-
old_pageviews = Pageview.where('visited_at < ?', cutoff_date)
-
count = old_pageviews.count
-
-
return 0 if count == 0
-
-
# Archive in batches to avoid memory issues
-
old_pageviews.find_in_batches(batch_size: 1000) do |batch|
-
archived_data = batch.map do |pv|
-
{
-
id: pv.id,
-
path: pv.path,
-
title: pv.title,
-
referrer: pv.referrer,
-
user_agent: pv.user_agent,
-
browser: pv.browser,
-
device: pv.device,
-
os: pv.os,
-
ip_hash: pv.ip_hash,
-
session_id: pv.session_id,
-
user_id: pv.user_id,
-
post_id: pv.post_id,
-
page_id: pv.page_id,
-
unique_visitor: pv.unique_visitor,
-
returning_visitor: pv.returning_visitor,
-
bot: pv.bot,
-
consented: pv.consented,
-
visited_at: pv.visited_at,
-
metadata: pv.metadata,
-
tenant_id: pv.tenant_id,
-
reading_time: pv.reading_time,
-
scroll_depth: pv.scroll_depth,
-
completion_rate: pv.completion_rate,
-
time_on_page: pv.time_on_page,
-
exit_intent: pv.exit_intent,
-
country_code: pv.country_code,
-
country_name: pv.country_name,
-
city: pv.city,
-
region: pv.region,
-
latitude: pv.latitude,
-
longitude: pv.longitude,
-
timezone: pv.timezone,
-
archived_at: Time.current
-
}
-
end
-
-
ArchivedPageview.insert_all(archived_data)
-
end
-
-
# Delete original records
-
old_pageviews.delete_all
-
-
count
-
end
-
-
def archive_analytics_events(cutoff_date)
-
old_events = AnalyticsEvent.where('created_at < ?', cutoff_date)
-
count = old_events.count
-
-
return 0 if count == 0
-
-
# Archive in batches
-
old_events.find_in_batches(batch_size: 1000) do |batch|
-
archived_data = batch.map do |event|
-
{
-
id: event.id,
-
event_name: event.event_name,
-
properties: event.properties,
-
session_id: event.session_id,
-
user_id: event.user_id,
-
tenant_id: event.tenant_id,
-
created_at: event.created_at,
-
archived_at: Time.current
-
}
-
end
-
-
ArchivedAnalyticsEvent.insert_all(archived_data)
-
end
-
-
# Delete original records
-
old_events.delete_all
-
-
count
-
end
-
-
def export_pageviews(start_date, end_date)
-
Pageview.where(visited_at: start_date..end_date)
-
.includes(:tenant)
-
.map do |pv|
-
{
-
id: pv.id,
-
path: pv.path,
-
title: pv.title,
-
referrer: pv.referrer,
-
browser: pv.browser,
-
device: pv.device,
-
os: pv.os,
-
unique_visitor: pv.unique_visitor,
-
returning_visitor: pv.returning_visitor,
-
bot: pv.bot,
-
consented: pv.consented,
-
visited_at: pv.visited_at,
-
reading_time: pv.reading_time,
-
scroll_depth: pv.scroll_depth,
-
completion_rate: pv.completion_rate,
-
time_on_page: pv.time_on_page,
-
country_code: pv.country_code,
-
country_name: pv.country_name,
-
city: pv.city,
-
region: pv.region,
-
tenant_name: pv.tenant&.name
-
}
-
end
-
end
-
-
def export_analytics_events(start_date, end_date)
-
AnalyticsEvent.where(created_at: start_date..end_date)
-
.includes(:tenant)
-
.map do |event|
-
{
-
id: event.id,
-
event_name: event.event_name,
-
properties: event.properties,
-
session_id: event.session_id,
-
user_id: event.user_id,
-
created_at: event.created_at,
-
tenant_name: event.tenant&.name
-
}
-
end
-
end
-
-
def convert_to_csv(data)
-
require 'csv'
-
-
csv_string = CSV.generate do |csv|
-
# Header
-
csv << ['Type', 'ID', 'Date', 'Path', 'Event', 'Properties', 'Country', 'Device', 'Browser', 'Tenant']
-
-
# Pageviews
-
data[:pageviews].each do |pv|
-
csv << [
-
'pageview',
-
pv[:id],
-
pv[:visited_at],
-
pv[:path],
-
nil,
-
nil,
-
pv[:country_name],
-
pv[:device],
-
pv[:browser],
-
pv[:tenant_name]
-
]
-
end
-
-
# Events
-
data[:events].each do |event|
-
csv << [
-
'event',
-
event[:id],
-
event[:created_at],
-
nil,
-
event[:event_name],
-
event[:properties].to_json,
-
nil,
-
nil,
-
nil,
-
event[:tenant_name]
-
]
-
end
-
end
-
-
csv_string
-
end
-
-
def convert_to_parquet(data)
-
# For now, return JSON - could implement Parquet export with ruby-parquet gem
-
data.to_json
-
end
-
-
def cleanup_archived_data_details
-
cutoff_date = (@default_retention_days * 2).days.ago # Keep archived data for 2x retention period
-
-
ArchivedPageview.where('archived_at < ?', cutoff_date).delete_all
-
ArchivedAnalyticsEvent.where('archived_at < ?', cutoff_date).delete_all
-
end
-
-
def oldest_data_date
-
[
-
Pageview.minimum(:visited_at),
-
AnalyticsEvent.minimum(:created_at),
-
ArchivedPageview.minimum(:visited_at),
-
ArchivedAnalyticsEvent.minimum(:created_at)
-
].compact.min
-
end
-
end
-
class AnalyticsRetentionService
-
# Clean up old analytics data to prevent database bloat
-
def self.cleanup_old_data
-
retention_days = SiteSetting.get('analytics_data_retention_days', 365)
-
cutoff_date = retention_days.days.ago
-
-
# Archive old pageviews before deletion
-
archive_old_pageviews(cutoff_date)
-
-
# Delete old analytics events
-
old_events_count = AnalyticsEvent.where('created_at < ?', cutoff_date).count
-
AnalyticsEvent.where('created_at < ?', cutoff_date).delete_all
-
-
# Delete old pageviews (keep only essential data)
-
old_pageviews_count = Pageview.where('visited_at < ?', cutoff_date).count
-
Pageview.where('visited_at < ?', cutoff_date).delete_all
-
-
Rails.logger.info "Analytics cleanup completed: #{old_pageviews_count} pageviews and #{old_events_count} events removed"
-
-
{
-
pageviews_deleted: old_pageviews_count,
-
events_deleted: old_events_count,
-
cutoff_date: cutoff_date
-
}
-
end
-
-
# Archive old pageviews to compressed files
-
def self.archive_old_pageviews(cutoff_date)
-
return unless SiteSetting.get('analytics_archive_enabled', true)
-
-
# Create archive directory
-
archive_dir = Rails.root.join('storage', 'analytics_archive')
-
FileUtils.mkdir_p(archive_dir)
-
-
# Get old pageviews in batches
-
batch_size = 10000
-
total_archived = 0
-
-
Pageview.where('visited_at < ?', cutoff_date).find_in_batches(batch_size: batch_size) do |batch|
-
archive_data = batch.map do |pv|
-
{
-
path: pv.path,
-
title: pv.title,
-
visited_at: pv.visited_at,
-
session_id: pv.session_id,
-
is_reader: pv.is_reader,
-
engagement_score: pv.engagement_score,
-
reading_time: pv.reading_time,
-
country_code: pv.country_code
-
}
-
end
-
-
# Write to compressed archive file
-
archive_filename = "pageviews_#{cutoff_date.strftime('%Y%m')}.json.gz"
-
archive_path = archive_dir.join(archive_filename)
-
-
File.open(archive_path, 'a') do |file|
-
file.write(Zlib::Deflate.deflate(JSON.dump(archive_data)))
-
end
-
-
total_archived += batch.size
-
end
-
-
Rails.logger.info "Archived #{total_archived} pageviews to #{archive_dir}"
-
total_archived
-
end
-
-
# Get analytics summary for archived data
-
def self.archived_summary(year, month)
-
archive_dir = Rails.root.join('storage', 'analytics_archive')
-
archive_filename = "pageviews_#{year}#{month.to_s.rjust(2, '0')}.json.gz"
-
archive_path = archive_dir.join(archive_filename)
-
-
return {} unless File.exist?(archive_path)
-
-
archived_data = JSON.parse(Zlib::Inflate.inflate(File.read(archive_path)))
-
-
{
-
total_pageviews: archived_data.size,
-
unique_readers: archived_data.count { |pv| pv['is_reader'] },
-
avg_engagement: archived_data.sum { |pv| pv['engagement_score'] || 0 } / archived_data.size.to_f,
-
top_pages: archived_data.group_by { |pv| pv['path'] }
-
.transform_values(&:size)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.to_h
-
}
-
end
-
end
-
# frozen_string_literal: true
-
-
class AnalyticsSecurityService
-
# Advanced security measures for analytics data
-
-
class << self
-
# Encrypt sensitive analytics data
-
def encrypt_sensitive_data(data, user_id = nil)
-
return data unless data.is_a?(Hash)
-
-
encrypted_data = data.dup
-
-
# Encrypt PII fields
-
sensitive_fields = %w[email phone name address ip_address user_agent]
-
-
sensitive_fields.each do |field|
-
if encrypted_data[field].present?
-
encrypted_data[field] = encrypt_field(encrypted_data[field], user_id)
-
end
-
end
-
-
# Encrypt nested PII
-
if encrypted_data[:properties].is_a?(Hash)
-
encrypted_data[:properties] = encrypt_sensitive_data(encrypted_data[:properties], user_id)
-
end
-
-
encrypted_data
-
end
-
-
# Decrypt sensitive analytics data (admin only)
-
def decrypt_sensitive_data(encrypted_data, user_id = nil, admin_user = nil)
-
return encrypted_data unless admin_user&.administrator?
-
-
decrypted_data = encrypted_data.dup
-
-
# Decrypt PII fields
-
sensitive_fields = %w[email phone name address ip_address user_agent]
-
-
sensitive_fields.each do |field|
-
if decrypted_data[field].present? && is_encrypted?(decrypted_data[field])
-
decrypted_data[field] = decrypt_field(decrypted_data[field], user_id)
-
end
-
end
-
-
# Decrypt nested PII
-
if decrypted_data[:properties].is_a?(Hash)
-
decrypted_data[:properties] = decrypt_sensitive_data(decrypted_data[:properties], user_id, admin_user)
-
end
-
-
decrypted_data
-
end
-
-
# Anonymize IP addresses based on GDPR settings
-
def anonymize_ip(ip_address, anonymization_level = :full)
-
return nil if ip_address.blank?
-
-
case anonymization_level
-
when :full
-
# Full anonymization - remove last octet
-
parts = ip_address.split('.')
-
parts[3] = '0' if parts.length == 4
-
parts.join('.')
-
when :partial
-
# Partial anonymization - remove last two octets
-
parts = ip_address.split('.')
-
parts[2] = '0'
-
parts[3] = '0' if parts.length == 4
-
parts.join('.')
-
when :none
-
# No anonymization (only if GDPR consent given)
-
ip_address
-
else
-
# Default to full anonymization
-
anonymize_ip(ip_address, :full)
-
end
-
end
-
-
# Hash user identifiers for privacy
-
def hash_user_identifier(identifier, salt = nil)
-
return nil if identifier.blank?
-
-
salt ||= Rails.application.secrets.secret_key_base
-
Digest::SHA256.hexdigest("#{identifier}#{salt}")
-
end
-
-
# Generate secure session IDs
-
def generate_secure_session_id
-
SecureRandom.hex(32)
-
end
-
-
# Validate analytics request authenticity
-
def validate_request_authenticity(request)
-
# Check for valid CSRF token
-
return false unless valid_csrf_token?(request)
-
-
# Check for suspicious patterns
-
return false if suspicious_request?(request)
-
-
# Check rate limiting
-
return false if rate_limited?(request)
-
-
true
-
end
-
-
# Implement data retention policies
-
def apply_data_retention_policy(data_type, record_age)
-
retention_days = get_retention_period(data_type)
-
-
return false if retention_days.nil?
-
-
record_age > retention_days.days
-
end
-
-
# Audit analytics data access
-
def audit_data_access(user_id, data_type, action, admin_user = nil)
-
audit_data = {
-
user_id: user_id,
-
data_type: data_type,
-
action: action,
-
admin_user_id: admin_user&.id,
-
timestamp: Time.current,
-
ip_address: anonymize_ip(get_current_ip),
-
user_agent: get_current_user_agent
-
}
-
-
# Store audit log
-
AnalyticsAuditLog.create!(audit_data)
-
-
# Check for suspicious access patterns
-
check_suspicious_access_patterns(user_id, data_type, action)
-
end
-
-
# Implement data masking for non-admin users
-
def mask_sensitive_data(data, user_role = :user)
-
return data if user_role == :admin
-
-
masked_data = data.dup
-
-
# Mask PII fields
-
pii_fields = %w[email phone name address ip_address]
-
pii_fields.each do |field|
-
if masked_data[field].present?
-
masked_data[field] = mask_field(masked_data[field])
-
end
-
end
-
-
masked_data
-
end
-
-
# Implement data pseudonymization
-
def pseudonymize_data(data, pseudonymization_key)
-
return data unless data.is_a?(Hash)
-
-
pseudonymized_data = data.dup
-
-
# Pseudonymize identifiers
-
identifier_fields = %w[user_id session_id device_id]
-
identifier_fields.each do |field|
-
if pseudonymized_data[field].present?
-
pseudonymized_data[field] = hash_user_identifier(
-
pseudonymized_data[field],
-
pseudonymization_key
-
)
-
end
-
end
-
-
pseudonymized_data
-
end
-
-
# Implement data minimization
-
def minimize_data_collection(data, purpose)
-
return data unless data.is_a?(Hash)
-
-
# Define minimal data sets for different purposes
-
minimal_sets = {
-
analytics: %w[page_path timestamp device browser],
-
marketing: %w[user_id preferences interests],
-
security: %w[ip_address user_agent timestamp],
-
performance: %w[page_load_time resource_metrics]
-
}
-
-
allowed_fields = minimal_sets[purpose.to_sym] || minimal_sets[:analytics]
-
-
data.select { |key, _| allowed_fields.include?(key.to_s) }
-
end
-
-
# Implement consent management
-
def manage_consent(user_id, consent_type, granted, purpose = nil)
-
consent_data = {
-
user_id: user_id,
-
consent_type: consent_type,
-
granted: granted,
-
purpose: purpose,
-
timestamp: Time.current,
-
ip_address: anonymize_ip(get_current_ip),
-
user_agent: get_current_user_agent
-
}
-
-
# Store consent record
-
AnalyticsConsent.create!(consent_data)
-
-
# Update user consent status
-
update_user_consent_status(user_id, consent_type, granted)
-
-
# Apply consent-based data processing
-
apply_consent_based_processing(user_id, consent_type, granted)
-
end
-
-
# Implement data portability
-
def export_user_data(user_id, format = :json)
-
user_data = collect_user_data(user_id)
-
-
case format
-
when :json
-
export_json_data(user_data)
-
when :csv
-
export_csv_data(user_data)
-
when :xml
-
export_xml_data(user_data)
-
else
-
export_json_data(user_data)
-
end
-
end
-
-
# Implement right to be forgotten
-
def delete_user_data(user_id, data_types = :all)
-
deletion_log = {
-
user_id: user_id,
-
data_types: data_types,
-
timestamp: Time.current,
-
admin_user_id: get_current_admin_user&.id
-
}
-
-
case data_types
-
when :all
-
delete_all_user_data(user_id)
-
when :analytics
-
delete_analytics_data(user_id)
-
when :personal
-
delete_personal_data(user_id)
-
else
-
delete_specific_data_types(user_id, data_types)
-
end
-
-
# Log deletion
-
AnalyticsDataDeletion.create!(deletion_log)
-
end
-
-
# Implement data breach detection
-
def detect_data_breach(user_id = nil)
-
breach_indicators = {
-
unusual_access_patterns: detect_unusual_access_patterns(user_id),
-
suspicious_requests: detect_suspicious_requests(user_id),
-
data_exfiltration: detect_data_exfiltration(user_id),
-
unauthorized_access: detect_unauthorized_access(user_id)
-
}
-
-
if breach_indicators.values.any?
-
handle_potential_breach(user_id, breach_indicators)
-
end
-
-
breach_indicators
-
end
-
-
private
-
-
def encrypt_field(value, user_id)
-
return value if value.blank?
-
-
key = generate_encryption_key(user_id)
-
cipher = OpenSSL::Cipher.new('AES-256-GCM')
-
cipher.encrypt
-
cipher.key = key
-
-
encrypted = cipher.update(value) + cipher.final
-
"#{cipher.iv.unpack1('H*')}:#{encrypted.unpack1('H*')}"
-
end
-
-
def decrypt_field(encrypted_value, user_id)
-
return encrypted_value unless is_encrypted?(encrypted_value)
-
-
iv_hex, encrypted_hex = encrypted_value.split(':')
-
return encrypted_value unless iv_hex && encrypted_hex
-
-
key = generate_encryption_key(user_id)
-
cipher = OpenSSL::Cipher.new('AES-256-GCM')
-
cipher.decrypt
-
cipher.key = key
-
cipher.iv = [iv_hex].pack('H*')
-
-
encrypted_data = [encrypted_hex].pack('H*')
-
cipher.update(encrypted_data) + cipher.final
-
rescue => e
-
Rails.logger.error "Decryption failed: #{e.message}"
-
encrypted_value
-
end
-
-
def is_encrypted?(value)
-
value.is_a?(String) && value.include?(':') && value.length > 64
-
end
-
-
def generate_encryption_key(user_id)
-
salt = Rails.application.secrets.secret_key_base
-
Digest::SHA256.digest("#{user_id}#{salt}")
-
end
-
-
def valid_csrf_token?(request)
-
# Implement CSRF validation
-
true # Simplified for now
-
end
-
-
def suspicious_request?(request)
-
# Check for suspicious patterns
-
user_agent = request.user_agent.to_s.downcase
-
-
# Block known bot patterns
-
bot_patterns = %w[bot crawler spider scraper]
-
return true if bot_patterns.any? { |pattern| user_agent.include?(pattern) }
-
-
# Check for unusual request patterns
-
ip_address = request.remote_ip
-
request_count = Redis.current.get("request_count:#{ip_address}").to_i
-
-
return true if request_count > 100 # Rate limit exceeded
-
-
# Update request count
-
Redis.current.incr("request_count:#{ip_address}")
-
Redis.current.expire("request_count:#{ip_address}", 1.hour.to_i)
-
-
false
-
end
-
-
def rate_limited?(request)
-
ip_address = request.remote_ip
-
key = "rate_limit:#{ip_address}:#{Time.current.to_i / 60}"
-
-
current_count = Redis.current.get(key).to_i
-
return true if current_count >= 60 # 60 requests per minute
-
-
Redis.current.incr(key)
-
Redis.current.expire(key, 1.minute.to_i)
-
-
false
-
end
-
-
def get_retention_period(data_type)
-
case data_type
-
when :analytics
-
SiteSetting.get('analytics_data_retention_days', 365).to_i
-
when :personal
-
SiteSetting.get('personal_data_retention_days', 30).to_i
-
when :marketing
-
SiteSetting.get('marketing_data_retention_days', 90).to_i
-
else
-
SiteSetting.get('default_data_retention_days', 365).to_i
-
end
-
end
-
-
def get_current_ip
-
# Get current request IP
-
Thread.current[:current_request]&.remote_ip || '127.0.0.1'
-
end
-
-
def get_current_user_agent
-
# Get current request user agent
-
Thread.current[:current_request]&.user_agent || 'Unknown'
-
end
-
-
def get_current_admin_user
-
# Get current admin user from thread
-
Thread.current[:current_admin_user]
-
end
-
-
def mask_field(value)
-
return value if value.blank?
-
-
if value.include?('@')
-
# Email masking
-
parts = value.split('@')
-
parts[0] = "#{parts[0][0]}***"
-
parts.join('@')
-
elsif value.match?(/^\d+$/)
-
# Phone number masking
-
"#{value[0..2]}***#{value[-2..-1]}"
-
else
-
# General masking
-
"#{value[0..2]}***"
-
end
-
end
-
-
def update_user_consent_status(user_id, consent_type, granted)
-
# Update user consent status in Redis/database
-
Redis.current.hset("user_consent:#{user_id}", consent_type, granted)
-
end
-
-
def apply_consent_based_processing(user_id, consent_type, granted)
-
# Apply consent-based data processing rules
-
case consent_type
-
when :analytics
-
if granted
-
enable_analytics_tracking(user_id)
-
else
-
disable_analytics_tracking(user_id)
-
end
-
when :marketing
-
if granted
-
enable_marketing_tracking(user_id)
-
else
-
disable_marketing_tracking(user_id)
-
end
-
end
-
end
-
-
def enable_analytics_tracking(user_id)
-
Redis.current.hset("user_tracking:#{user_id}", "analytics_enabled", true)
-
end
-
-
def disable_analytics_tracking(user_id)
-
Redis.current.hset("user_tracking:#{user_id}", "analytics_enabled", false)
-
end
-
-
def enable_marketing_tracking(user_id)
-
Redis.current.hset("user_tracking:#{user_id}", "marketing_enabled", true)
-
end
-
-
def disable_marketing_tracking(user_id)
-
Redis.current.hset("user_tracking:#{user_id}", "marketing_enabled", false)
-
end
-
-
def collect_user_data(user_id)
-
{
-
pageviews: Pageview.where(user_id: user_id).limit(1000),
-
events: AnalyticsEvent.where(user_id: user_id).limit(1000),
-
consent_records: AnalyticsConsent.where(user_id: user_id),
-
profile_data: get_user_profile_data(user_id)
-
}
-
end
-
-
def export_json_data(data)
-
data.to_json
-
end
-
-
def export_csv_data(data)
-
# Convert to CSV format
-
CSV.generate do |csv|
-
data.each do |key, records|
-
if records.is_a?(ActiveRecord::Relation)
-
csv << [key.to_s]
-
records.each { |record| csv << record.attributes.values }
-
end
-
end
-
end
-
end
-
-
def export_xml_data(data)
-
data.to_xml
-
end
-
-
def delete_all_user_data(user_id)
-
Pageview.where(user_id: user_id).delete_all
-
AnalyticsEvent.where(user_id: user_id).delete_all
-
AnalyticsConsent.where(user_id: user_id).delete_all
-
AnalyticsAuditLog.where(user_id: user_id).delete_all
-
end
-
-
def delete_analytics_data(user_id)
-
Pageview.where(user_id: user_id).delete_all
-
AnalyticsEvent.where(user_id: user_id).delete_all
-
end
-
-
def delete_personal_data(user_id)
-
# Delete personal data while keeping analytics data anonymized
-
AnalyticsEvent.where(user_id: user_id).update_all(user_id: nil)
-
Pageview.where(user_id: user_id).update_all(user_id: nil)
-
end
-
-
def delete_specific_data_types(user_id, data_types)
-
data_types.each do |data_type|
-
case data_type
-
when :pageviews
-
Pageview.where(user_id: user_id).delete_all
-
when :events
-
AnalyticsEvent.where(user_id: user_id).delete_all
-
when :consent
-
AnalyticsConsent.where(user_id: user_id).delete_all
-
end
-
end
-
end
-
-
def detect_unusual_access_patterns(user_id)
-
# Detect unusual access patterns
-
false # Simplified for now
-
end
-
-
def detect_suspicious_requests(user_id)
-
# Detect suspicious requests
-
false # Simplified for now
-
end
-
-
def detect_data_exfiltration(user_id)
-
# Detect data exfiltration attempts
-
false # Simplified for now
-
end
-
-
def detect_unauthorized_access(user_id)
-
# Detect unauthorized access
-
false # Simplified for now
-
end
-
-
def handle_potential_breach(user_id, breach_indicators)
-
# Handle potential data breach
-
Rails.logger.warn "Potential data breach detected for user #{user_id}: #{breach_indicators}"
-
end
-
-
def check_suspicious_access_patterns(user_id, data_type, action)
-
# Check for suspicious access patterns
-
access_key = "access_pattern:#{user_id}:#{data_type}"
-
access_count = Redis.current.incr(access_key)
-
Redis.current.expire(access_key, 1.hour.to_i)
-
-
if access_count > 50 # Suspicious if more than 50 accesses per hour
-
handle_suspicious_access(user_id, data_type, action, access_count)
-
end
-
end
-
-
def handle_suspicious_access(user_id, data_type, action, count)
-
Rails.logger.warn "Suspicious access pattern: User #{user_id} accessed #{data_type} #{count} times in the last hour"
-
end
-
-
def get_user_profile_data(user_id)
-
# Get user profile data
-
User.find_by(id: user_id)&.attributes || {}
-
end
-
end
-
end
-
# Advanced Analytics Service - GA4-like features with GDPR compliance
-
class AnalyticsService
-
include AnalyticsHelper
-
-
# Real-time analytics
-
def self.realtime_stats
-
{
-
active_users: Pageview.where('visited_at >= ?', 5.minutes.ago).non_bot.distinct.count(:session_id),
-
current_pageviews: Pageview.where('visited_at >= ?', 1.minute.ago).count,
-
top_pages_now: Pageview.where('visited_at >= ?', 5.minutes.ago)
-
.non_bot
-
.group(:path)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(5)
-
.to_h,
-
active_countries: Pageview.where('visited_at >= ?', 5.minutes.ago)
-
.non_bot
-
.where.not(country_code: nil)
-
.group(:country_code)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(3)
-
.to_h
-
}
-
end
-
-
# Advanced audience insights
-
def self.audience_insights(period: :month)
-
range = period_range(period)
-
pageviews = Pageview.where(visited_at: range).non_bot.consented_only
-
-
{
-
# Demographics
-
top_countries: pageviews.where.not(country_code: nil)
-
.group(:country_code)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.to_h,
-
-
# Technology
-
browsers: pageviews.group(:browser).count(:id).sort_by { |_, count| -count }.to_h,
-
devices: pageviews.group(:device).count(:id).sort_by { |_, count| -count }.to_h,
-
operating_systems: pageviews.group(:os).count(:id).sort_by { |_, count| -count }.to_h,
-
-
# Behavior
-
avg_session_duration: pageviews.average(:duration)&.to_i || 0,
-
bounce_rate: calculate_bounce_rate(pageviews),
-
pages_per_session: calculate_pages_per_session(pageviews),
-
-
# Acquisition
-
traffic_sources: pageviews.where.not(referrer: [nil, ''])
-
.group(:referrer)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.to_h,
-
-
# Engagement
-
engagement_rate: calculate_engagement_rate(pageviews),
-
return_visitors: pageviews.where(returning_visitor: true).count,
-
new_visitors: pageviews.where(unique_visitor: true).count
-
}
-
end
-
-
# Content performance
-
def self.content_performance(period: :month)
-
range = period_range(period)
-
pageviews = Pageview.where(visited_at: range).non_bot.consented_only
-
-
{
-
top_posts: pageviews.where.not(post_id: nil)
-
.group(:post_id)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.map do |post_id, count|
-
post = Post.find_by(id: post_id)
-
{
-
post: post,
-
views: count,
-
unique_views: pageviews.where(post_id: post_id, unique_visitor: true).count,
-
avg_duration: pageviews.where(post_id: post_id).average(:duration)&.to_i || 0
-
}
-
end,
-
-
top_pages: pageviews.where.not(page_id: nil)
-
.group(:page_id)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.map do |page_id, count|
-
page_obj = Page.find_by(id: page_id)
-
{
-
page: page_obj,
-
views: count,
-
unique_views: pageviews.where(page_id: page_id, unique_visitor: true).count,
-
avg_duration: pageviews.where(page_id: page_id).average(:duration)&.to_i || 0
-
}
-
end,
-
-
top_paths: pageviews.group(:path)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.to_h
-
}
-
end
-
-
# Conversion tracking (custom events)
-
def self.track_event(event_name, properties = {})
-
# Create a custom event record
-
AnalyticsEvent.create!(
-
event_name: event_name,
-
properties: properties,
-
session_id: properties[:session_id] || generate_session_id,
-
user_id: properties[:user_id],
-
path: properties[:path] || '/',
-
tenant: properties[:tenant] || ActsAsTenant.current_tenant || Tenant.first
-
)
-
rescue => e
-
Rails.logger.error "Failed to track event: #{e.message}"
-
nil
-
end
-
-
# Advanced metrics methods
-
def self.total_pageviews(period: :month)
-
range = period_range(period)
-
Pageview.where(visited_at: range).non_bot.consented_only.count
-
end
-
-
def self.unique_visitors(period: :month)
-
range = period_range(period)
-
Pageview.where(visited_at: range).non_bot.consented_only.distinct.count(:session_id)
-
end
-
-
def self.avg_session_duration(period: :month)
-
range = period_range(period)
-
avg_duration = Pageview.where(visited_at: range)
-
.non_bot
-
.consented_only
-
.average(:duration)
-
avg_duration ? avg_duration.to_i : 0
-
end
-
-
def self.bounce_rate(period: :month)
-
range = period_range(period)
-
pageviews = Pageview.where(visited_at: range).non_bot.consented_only
-
calculate_bounce_rate(pageviews)
-
end
-
-
def self.pages_per_session(period: :month)
-
range = period_range(period)
-
pageviews = Pageview.where(visited_at: range).non_bot.consented_only
-
calculate_pages_per_session(pageviews)
-
end
-
-
def self.traffic_sources(period: :month)
-
range = period_range(period)
-
pageviews = Pageview.where(visited_at: range).non_bot.consented_only
-
-
# Group by referrer and count
-
referrers = pageviews.where.not(referrer: [nil, ''])
-
.group(:referrer)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
-
# Categorize traffic sources
-
categorized_sources = {
-
'Direct' => pageviews.where(referrer: [nil, '']).count,
-
'Search' => 0,
-
'Social' => 0,
-
'Referral' => 0,
-
'Email' => 0,
-
'Other' => 0
-
}
-
-
referrers.each do |referrer, count|
-
if referrer.include?('google') || referrer.include?('bing') || referrer.include?('yahoo')
-
categorized_sources['Search'] += count
-
elsif referrer.include?('facebook') || referrer.include?('twitter') || referrer.include?('linkedin') || referrer.include?('instagram')
-
categorized_sources['Social'] += count
-
elsif referrer.include?('mail') || referrer.include?('email')
-
categorized_sources['Email'] += count
-
else
-
categorized_sources['Referral'] += count
-
end
-
end
-
-
categorized_sources
-
end
-
-
private
-
-
def self.generate_session_id
-
SecureRandom.hex(16)
-
end
-
-
# Funnel analysis
-
def self.funnel_analysis(funnel_steps, period: :month)
-
range = period_range(period)
-
results = {}
-
-
funnel_steps.each_with_index do |step, index|
-
if index == 0
-
# First step - all visitors
-
results[step] = Pageview.where(visited_at: range)
-
.non_bot
-
.consented_only
-
.distinct
-
.count(:session_id)
-
else
-
# Subsequent steps - visitors who completed previous step
-
previous_step_sessions = results[funnel_steps[index - 1]]
-
results[step] = Pageview.where(visited_at: range)
-
.non_bot
-
.consented_only
-
.where(session_id: previous_step_sessions)
-
.distinct
-
.count(:session_id)
-
end
-
end
-
-
results
-
end
-
-
# Cohort analysis
-
def self.cohort_analysis(period: :month)
-
range = period_range(period)
-
-
# Group users by their first visit week
-
cohorts = Pageview.where(visited_at: range)
-
.non_bot
-
.consented_only
-
.group("DATE_TRUNC('week', visited_at)")
-
.count(:session_id)
-
-
cohorts
-
end
-
-
# AI-powered automated insights
-
def self.generate_insights(period: :month)
-
range = period_range(period)
-
pageviews = Pageview.where(visited_at: range).non_bot.consented_only
-
-
insights = []
-
-
# Advanced traffic growth analysis
-
current_period_count = pageviews.count
-
previous_period_count = Pageview.where(visited_at: previous_period_range(period)).non_bot.consented_only.count
-
-
if current_period_count > previous_period_count && previous_period_count > 0
-
growth_percentage = ((current_period_count - previous_period_count).to_f / previous_period_count * 100).round(1)
-
if growth_percentage > 20
-
insights << {
-
type: 'growth',
-
title: '🚀 Significant Traffic Growth',
-
message: "Traffic increased by #{growth_percentage}% compared to the previous period",
-
action: "Analyze your top-performing content and marketing channels to replicate this success",
-
priority: 'high',
-
impact: 'positive'
-
}
-
elsif growth_percentage > 5
-
insights << {
-
type: 'growth',
-
title: '📈 Steady Growth',
-
message: "Traffic increased by #{growth_percentage}% compared to the previous period",
-
action: "Continue your current strategy while experimenting with new content formats",
-
priority: 'medium',
-
impact: 'positive'
-
}
-
end
-
elsif current_period_count < previous_period_count && previous_period_count > 0
-
decline_percentage = ((previous_period_count - current_period_count).to_f / previous_period_count * 100).round(1)
-
insights << {
-
type: 'decline',
-
title: '📉 Traffic Decline Detected',
-
message: "Traffic decreased by #{decline_percentage}% compared to the previous period",
-
action: "Review your content strategy and check for technical issues affecting SEO",
-
priority: 'high',
-
impact: 'negative'
-
}
-
end
-
-
# Engagement analysis
-
avg_engagement = pageviews.where.not(engagement_score: nil).average(:engagement_score) || 0
-
readers_count = pageviews.where(is_reader: true).count
-
reader_rate = readers_count > 0 ? (readers_count.to_f / pageviews.count * 100).round(1) : 0
-
-
if reader_rate > 40
-
insights << {
-
type: 'engagement',
-
title: '⭐ High Reader Engagement',
-
message: "#{reader_rate}% of your visitors qualify as readers (30+ seconds)",
-
action: "Your content is highly engaging! Consider creating more in-depth content",
-
priority: 'high',
-
impact: 'positive'
-
}
-
elsif reader_rate < 20
-
insights << {
-
type: 'engagement',
-
title: '⚠️ Low Reader Engagement',
-
message: "Only #{reader_rate}% of visitors are reading your content",
-
action: "Improve content quality, add visual elements, and optimize for readability",
-
priority: 'high',
-
impact: 'negative'
-
}
-
end
-
-
# Content performance insights
-
top_posts = pageviews.where.not(post_id: nil)
-
.group(:post_id)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(3)
-
-
if top_posts.any?
-
top_post = top_posts.first
-
post = Post.find_by(id: top_post[0])
-
if post
-
insights << {
-
type: 'content',
-
title: '🏆 Top Performing Content',
-
message: "#{post.title} is your best performer with #{top_post[1]} views",
-
action: "Analyze what makes this content successful and create similar pieces",
-
priority: 'medium',
-
impact: 'positive'
-
}
-
end
-
end
-
-
# Geographic insights
-
top_countries = pageviews.where.not(country_code: nil)
-
.group(:country_code)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(3)
-
-
if top_countries.any?
-
top_country = top_countries.first
-
country_percentage = (top_country[1].to_f / pageviews.count * 100).round(1)
-
if country_percentage > 40
-
insights << {
-
type: 'geography',
-
title: '🌍 Geographic Concentration',
-
message: "#{top_country[0]} represents #{country_percentage}% of your traffic",
-
action: "Consider creating localized content or targeting expansion to new markets",
-
priority: 'medium',
-
impact: 'neutral'
-
}
-
end
-
end
-
-
# Device and technology insights
-
mobile_count = pageviews.where(device: 'Mobile').count
-
mobile_percentage = (mobile_count.to_f / pageviews.count * 100).round(1)
-
-
if mobile_percentage > 70
-
insights << {
-
type: 'device',
-
title: '📱 Mobile-First Audience',
-
message: "#{mobile_percentage}% of your traffic is from mobile devices",
-
action: "Ensure your site is fully optimized for mobile and consider mobile-specific content",
-
priority: 'high',
-
impact: 'positive'
-
}
-
elsif mobile_percentage < 30
-
insights << {
-
type: 'device',
-
title: '💻 Desktop-Dominant Traffic',
-
message: "Only #{mobile_percentage}% of traffic is mobile",
-
action: "Consider mobile optimization to reach a broader audience",
-
priority: 'medium',
-
impact: 'neutral'
-
}
-
end
-
-
# Traffic source insights
-
direct_traffic = pageviews.where(referrer: [nil, '']).count
-
direct_percentage = (direct_traffic.to_f / pageviews.count * 100).round(1)
-
-
if direct_percentage > 60
-
insights << {
-
type: 'traffic',
-
title: '🎯 Strong Brand Recognition',
-
message: "#{direct_percentage}% of your traffic is direct visits",
-
action: "Your brand awareness is strong! Consider expanding your content marketing",
-
priority: 'high',
-
impact: 'positive'
-
}
-
end
-
-
# Performance insights
-
slow_pages = pageviews.where('time_on_page < ?', 10).count
-
slow_percentage = (slow_pages.to_f / pageviews.count * 100).round(1)
-
-
if slow_percentage > 50
-
insights << {
-
type: 'performance',
-
title: '⚡ Page Speed Issues',
-
message: "#{slow_percentage}% of visitors spend less than 10 seconds on your pages",
-
action: "Optimize page load times and improve content engagement",
-
priority: 'high',
-
impact: 'negative'
-
}
-
end
-
-
# Conversion insights (if conversions are tracked)
-
conversions = AnalyticsEvent.where(created_at: range, event_name: 'conversion').count
-
if conversions > 0
-
conversion_rate = (conversions.to_f / pageviews.count * 100).round(2)
-
insights << {
-
type: 'conversion',
-
title: '💰 Conversion Performance',
-
message: "#{conversions} conversions with a #{conversion_rate}% conversion rate",
-
action: "Analyze your conversion funnel and optimize high-performing pages",
-
priority: 'high',
-
impact: 'positive'
-
}
-
end
-
-
# Sort insights by priority and impact
-
insights.sort_by do |insight|
-
priority_score = case insight[:priority]
-
when 'high' then 3
-
when 'medium' then 2
-
when 'low' then 1
-
else 0
-
end
-
-
impact_score = case insight[:impact]
-
when 'positive' then 1
-
when 'negative' then 2
-
when 'neutral' then 0
-
else 0
-
end
-
-
-(priority_score + impact_score)
-
end
-
end
-
-
private
-
-
def self.period_range(period)
-
case period.to_sym
-
when :today
-
Time.current.beginning_of_day..Time.current.end_of_day
-
when :week
-
1.week.ago..Time.current
-
when :month
-
1.month.ago..Time.current
-
when :year
-
1.year.ago..Time.current
-
else
-
1.month.ago..Time.current
-
end
-
end
-
-
def self.previous_period_range(period)
-
case period.to_sym
-
when :today
-
Time.current.beginning_of_day - 1.day..Time.current.end_of_day - 1.day
-
when :week
-
2.weeks.ago..1.week.ago
-
when :month
-
2.months.ago..1.month.ago
-
when :year
-
2.years.ago..1.year.ago
-
else
-
2.months.ago..1.month.ago
-
end
-
end
-
-
def self.calculate_bounce_rate(pageviews)
-
total_sessions = pageviews.distinct.count(:session_id)
-
return 0 if total_sessions.zero?
-
-
single_page_sessions = pageviews.group(:session_id)
-
.having('COUNT(*) = 1')
-
.count
-
.size
-
-
((single_page_sessions.to_f / total_sessions) * 100).round(1)
-
end
-
-
def self.calculate_pages_per_session(pageviews)
-
total_sessions = pageviews.distinct.count(:session_id)
-
return 0 if total_sessions.zero?
-
-
total_pageviews = pageviews.count
-
(total_pageviews.to_f / total_sessions).round(2)
-
end
-
-
def self.calculate_engagement_rate(pageviews)
-
total_pageviews = pageviews.count
-
return 0 if total_pageviews.zero?
-
-
engaged_sessions = pageviews.where('duration > ?', 30).count
-
((engaged_sessions.to_f / total_pageviews) * 100).round(1)
-
end
-
end
-
class BuilderLiquidRenderer
-
attr_reader :builder_theme, :theme_file_manager
-
-
def initialize(builder_theme)
-
@builder_theme = builder_theme
-
@themes_manager = ThemesManager.new
-
-
# Configure Liquid to allow includes and renders
-
setup_liquid_file_system
-
end
-
-
# Render a template using the builder theme's data
-
def render_template(template_name, context = {})
-
# Get rendered file data (template + layout + sections + settings)
-
rendered_data = builder_theme.get_rendered_file(template_name)
-
return '<div class="error">Template not found</div>' unless rendered_data
-
-
# Get layout content from filesystem
-
layout_content = rendered_data[:layout_content]
-
return '<div class="error">Layout not found</div>' unless layout_content
-
-
# Render sections based on file settings
-
sections_html = render_sections_from_rendered_data(rendered_data, context)
-
-
# Replace content_for_layout with rendered sections
-
layout_content = layout_content.gsub('{{ content_for_layout }}', sections_html)
-
-
# Render the layout with all sections and settings
-
render_layout_with_sections(layout_content, context, rendered_data)
-
end
-
-
# Render a specific section using filesystem content + database settings
-
def render_section(section_id, section_data, context = {})
-
# Get section content from filesystem (latest developer changes)
-
section_content = get_section_content(section_data['type'])
-
return '' unless section_content
-
-
# Register custom filters
-
self.class.register_liquid_filters(builder_theme.id)
-
-
# Create liquid template with permissive settings
-
template = Liquid::Template.parse(section_content, error_mode: :strict)
-
-
# Prepare context with settings from database (user customizations)
-
liquid_context = {
-
'section' => {
-
'settings' => section_data['settings'] || {},
-
'id' => section_id,
-
'type' => section_data['type']
-
}
-
}.merge(context)
-
-
# Add context data based on section schema requirements
-
context_data = get_section_context(section_data['type'])
-
context_data.each do |key, value|
-
liquid_context["@#{key}"] = value
-
end
-
-
# Render section
-
template.render!(liquid_context)
-
rescue Liquid::Error => e
-
Rails.logger.error "Liquid error in section #{section_id}: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
"<div class='error'>Liquid error in section #{section_id}: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
-
rescue => e
-
Rails.logger.error "Error rendering section #{section_id}: #{e.message}"
-
"<div class='error'>Error rendering section: #{e.message}</div>"
-
end
-
-
# Render section with content and settings
-
def render_section_with_content(section, section_content, context = {})
-
return '' unless section_content
-
-
# Register custom filters
-
self.class.register_liquid_filters(builder_theme.id)
-
-
# Create liquid template with permissive settings
-
template = Liquid::Template.parse(section_content, error_mode: :strict)
-
-
# Prepare context with settings from database (user customizations)
-
liquid_context = {
-
'section' => {
-
'settings' => section.settings || {},
-
'id' => section.section_id,
-
'type' => section.section_type
-
}
-
}.merge(context)
-
-
# Add context data based on section schema requirements
-
context_data = get_section_context(section.section_type)
-
context_data.each do |key, value|
-
liquid_context["@#{key}"] = value
-
end
-
-
# Render section
-
template.render!(liquid_context)
-
rescue Liquid::Error => e
-
Rails.logger.error "Liquid error in section #{section.section_id}: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
"<div class='error'>Liquid error in section #{section.section_id}: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
-
rescue => e
-
Rails.logger.error "Error rendering section #{section.section_id}: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
"<div class='error'>Error rendering section: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
-
end
-
-
# Get all available template types
-
def available_templates
-
template_files = builder_theme.builder_theme_files.templates
-
template_files.map { |file| file.template_name }.compact
-
end
-
-
# Get all available section types
-
def available_sections
-
section_files = builder_theme.builder_theme_files.sections
-
section_files.map { |file| file.section_name }.compact
-
end
-
-
# Get template data - always from filesystem (latest changes)
-
def get_template_data(template_type)
-
@themes_manager.get_parsed_file("templates/#{template_type}.json") || {}
-
end
-
-
# Get section content - from filesystem or PublishedThemeFile
-
def get_section_content(section_type)
-
content = get_file_content("sections/#{section_type}.liquid") || ''
-
-
# Remove schema tags from section content for rendering
-
# Schema tags are used by the builder UI, not for rendering
-
content.gsub(/{%\s*schema\s*%}.*?{%\s*endschema\s*%}/m, '')
-
end
-
-
# Get layout content - from filesystem or PublishedThemeFile
-
def get_layout_content
-
get_file_content("layout/theme.liquid") || default_layout
-
end
-
-
# Get theme assets - from filesystem or PublishedThemeFile
-
def assets
-
{
-
css: get_file_content('assets/theme.css') || '',
-
js: get_file_content('assets/theme.js') || ''
-
}
-
end
-
-
private
-
-
# Get context data for a specific section based on its schema requirements
-
def get_section_context(section_type)
-
# Get the section schema to see what context it requests
-
section_schema = get_section_schema(section_type)
-
return {} unless section_schema&.dig('context_requests')
-
-
context_data = {}
-
section_schema['context_requests'].each do |key, request_config|
-
case key
-
when 'menus'
-
context_data[key] = get_menus_context
-
when 'pages'
-
context_data[key] = get_pages_context
-
when 'posts'
-
context_data[key] = get_posts_context
-
when 'categories'
-
context_data[key] = get_categories_context
-
when 'products'
-
context_data[key] = get_products_context
-
else
-
Rails.logger.warn "Unknown context request: #{key}"
-
end
-
end
-
-
context_data
-
end
-
-
def get_section_schema(section_type)
-
# Get the section schema from the theme files
-
theme_name = builder_theme.theme_name
-
manager = ThemesManager.new
-
schema_path = File.join(manager.themes_path, theme_name, 'sections', "#{section_type}.json")
-
-
return nil unless File.exist?(schema_path)
-
-
JSON.parse(File.read(schema_path))
-
rescue JSON::ParserError, Errno::ENOENT
-
nil
-
end
-
-
def get_menus_context
-
# Return available menus for navigation
-
# In a real system, this would query the database
-
[
-
{
-
id: 1,
-
name: 'Main Navigation',
-
menu_items: [
-
{ id: 1, title: 'Home', url: '/', order: 1 },
-
{ id: 2, title: 'About', url: '/about', order: 2 },
-
{ id: 3, title: 'Services', url: '/services', order: 3 },
-
{ id: 4, title: 'Contact', url: '/contact', order: 4 }
-
]
-
},
-
{
-
id: 2,
-
name: 'Footer Links',
-
menu_items: [
-
{ id: 5, title: 'Privacy Policy', url: '/privacy', order: 1 },
-
{ id: 6, title: 'Terms of Service', url: '/terms', order: 2 },
-
{ id: 7, title: 'Support', url: '/support', order: 3 }
-
]
-
}
-
]
-
end
-
-
def get_pages_context
-
# Return available pages
-
# In a real system, this would query the database
-
[
-
{ id: 1, title: 'Home', slug: 'home', url: '/' },
-
{ id: 2, title: 'About Us', slug: 'about', url: '/about' },
-
{ id: 3, title: 'Services', slug: 'services', url: '/services' },
-
{ id: 4, title: 'Contact', slug: 'contact', url: '/contact' },
-
{ id: 5, title: 'Privacy Policy', slug: 'privacy', url: '/privacy' }
-
]
-
end
-
-
def get_posts_context
-
# Return recent posts
-
# In a real system, this would query the database
-
[
-
{ id: 1, title: 'Welcome to Our Blog', slug: 'welcome-blog', url: '/blog/welcome-blog' },
-
{ id: 2, title: 'Getting Started Guide', slug: 'getting-started', url: '/blog/getting-started' }
-
]
-
end
-
-
def get_categories_context
-
# Return post categories
-
# In a real system, this would query the database
-
[
-
{ id: 1, name: 'News', slug: 'news' },
-
{ id: 2, name: 'Tutorials', slug: 'tutorials' },
-
{ id: 3, name: 'Updates', slug: 'updates' }
-
]
-
end
-
-
def get_products_context
-
# Return sample products (for e-commerce sections)
-
# In a real system, this would query the database
-
[
-
{ id: 1, title: 'Sample Product 1', price: 29.99, url: '/products/sample-1' },
-
{ id: 2, title: 'Sample Product 2', price: 49.99, url: '/products/sample-2' }
-
]
-
end
-
-
def setup_liquid_file_system
-
# Create a custom file system that can resolve includes from PublishedThemeFile
-
if @builder_theme.respond_to?(:instance_variable_get) &&
-
@builder_theme.instance_variable_get(:@published_version)
-
-
published_version = @builder_theme.instance_variable_get(:@published_version)
-
-
# Use PublishedVersion directly as the file system
-
Liquid::Template.file_system = published_version
-
Rails.logger.info "Set up PublishedVersion as Liquid file system"
-
else
-
# Use default file system for regular themes
-
Liquid::Template.file_system = Liquid::LocalFileSystem.new("/", "%s.liquid")
-
end
-
end
-
-
# Get file content from either PublishedThemeFile or ThemesManager
-
def get_file_content(file_path)
-
# Check if we're using PublishedThemeFile (FrontendRendererService)
-
if @builder_theme.respond_to?(:instance_variable_get) &&
-
@builder_theme.instance_variable_get(:@published_version)
-
-
published_version = @builder_theme.instance_variable_get(:@published_version)
-
file = published_version.published_theme_files.find_by(file_path: file_path)
-
return file&.content
-
end
-
-
# Otherwise use ThemesManager
-
@themes_manager.get_file(file_path)
-
end
-
-
# Get asset content - from filesystem or PublishedThemeFile
-
def get_asset_content(asset_path)
-
get_file_content(asset_path) || ''
-
end
-
-
# Get theme settings
-
def theme_settings
-
# Return empty hash since BuilderTheme doesn't have settings_data
-
{}
-
end
-
-
# Render preview - should use real data, not sample data
-
def render_preview(template_type = 'index')
-
# This method should not be used - use real context data instead
-
raise "render_preview should not be used - use render_template with real context data"
-
end
-
-
# Update template data
-
def update_template_data(template_type, template_data)
-
content = JSON.pretty_generate(template_data)
-
builder_theme.update_file("templates/#{template_type}.json", content)
-
end
-
-
# Update section content
-
def update_section_content(section_type, content)
-
builder_theme.update_file("sections/#{section_type}.liquid", content)
-
end
-
-
# Update layout content
-
def update_layout_content(content)
-
builder_theme.update_file("layout/theme.liquid", content)
-
end
-
-
# Update asset content
-
def update_asset_content(asset_type, content)
-
builder_theme.update_file("assets/theme.#{asset_type}", content)
-
end
-
-
# Update theme settings
-
def update_theme_settings(settings)
-
# BuilderTheme doesn't have settings_data, so we'll skip this for now
-
# In the future, this could be stored in a separate settings table
-
Rails.logger.info "Theme settings update requested: #{settings}"
-
end
-
-
private
-
-
def render_layout_with_sections(layout_content, context, rendered_data = {})
-
# Register custom filters
-
self.class.register_liquid_filters(builder_theme.id)
-
-
# Process section tags first
-
processed_content = process_section_tags(layout_content, context)
-
-
# Parse the layout as a Liquid template with permissive settings
-
template = Liquid::Template.parse(processed_content, error_mode: :strict)
-
-
# Prepare context with all available data
-
liquid_context = build_liquid_context(context, rendered_data)
-
-
# Render the layout
-
template.render!(liquid_context)
-
rescue Liquid::Error => e
-
Rails.logger.error "Liquid error in layout: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
"<div class='error'>Liquid error in layout: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
-
rescue => e
-
Rails.logger.error "Error rendering layout: #{e.message}"
-
"<div class='error'>Error rendering layout: #{e.message}</div>"
-
end
-
-
def process_section_tags(content, context)
-
# Replace {% section 'section_name' %} with rendered section content
-
content.gsub(/{%\s*section\s+['"]([^'"]+)['"]\s*%}/) do |match|
-
section_name = $1
-
render_section_by_name(section_name, context)
-
end
-
end
-
-
def render_section_by_name(section_name, context)
-
section_content = load_section_content(section_name)
-
return '' unless section_content
-
-
# Register custom filters
-
self.class.register_liquid_filters(builder_theme.id)
-
-
# Create liquid template with permissive settings
-
template = Liquid::Template.parse(section_content, error_mode: :strict)
-
-
# Prepare context
-
liquid_context = build_liquid_context(context)
-
-
# Add context data based on section schema requirements
-
context_data = get_section_context(section_name)
-
context_data.each do |key, value|
-
liquid_context["@#{key}"] = value
-
end
-
-
# Render section
-
template.render!(liquid_context)
-
rescue Liquid::Error => e
-
Rails.logger.error "Liquid error in section #{section_name}: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
"<div class='error'>Liquid error in section #{section_name}: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
-
rescue => e
-
Rails.logger.error "Error rendering section #{section_name}: #{e.message}"
-
"<div class='error'>Error rendering section #{section_name}: #{e.message}</div>"
-
end
-
-
def load_template_data(template_type)
-
template_data(template_type)
-
end
-
-
def load_section_content(section_type)
-
get_section_content(section_type)
-
end
-
-
def load_layout_content
-
layout_content
-
end
-
-
def asset_content(asset_path)
-
asset_file = builder_theme.get_file(asset_path)
-
asset_file&.content || ''
-
end
-
-
def render_sections_from_rendered_data(rendered_data, context)
-
sections_html = ''
-
-
# Get sections from template content with proper order
-
template_content = rendered_data[:template_content] || {}
-
sections = template_content['sections'] || {}
-
section_order = template_content['order'] || sections.keys
-
-
# Render sections in the correct order
-
section_order.each do |section_id|
-
section_data = sections[section_id]
-
next unless section_data
-
-
Rails.logger.info "Rendering section: #{section_id} (#{section_data['type']}) with settings: #{section_data['settings']}"
-
-
# Skip header/footer as they're rendered by the layout
-
next if %w[header footer].include?(section_data['type'])
-
-
# Get section content from filesystem
-
section_content = get_section_content(section_data['type'])
-
next unless section_content
-
-
# Create a mock section object for compatibility
-
section = OpenStruct.new(
-
section_id: section_id,
-
section_type: section_data['type'],
-
settings: section_data['settings'] || {}
-
)
-
-
# Render section with its settings
-
sections_html += render_section_with_content(section, section_content, context)
-
end
-
-
sections_html
-
rescue => e
-
Rails.logger.error "Error rendering template sections: #{e.message}"
-
"<div class='error'>Error rendering template: #{e.message}</div>"
-
end
-
-
def build_liquid_context(context = {}, rendered_data = {})
-
# Start with minimal base context - no sample data
-
base_context = {
-
'site' => {
-
'title' => 'RailsPress Site',
-
'description' => 'A RailsPress powered website',
-
'url' => 'https://example.com'
-
},
-
'page' => {
-
'title' => 'Page Title',
-
'url' => '/current-page'
-
}
-
}
-
-
# Add rendered data context
-
if rendered_data.present?
-
base_context.merge!({
-
'template_settings' => rendered_data[:template_settings] || {},
-
'layout_settings' => rendered_data[:layout_settings] || {},
-
'theme_settings' => rendered_data[:theme_settings] || {}
-
})
-
end
-
-
base_context.merge(context)
-
end
-
-
-
# Register all Liquid filters and tags
-
def self.register_liquid_filters(builder_theme_id = nil)
-
# Create a custom asset filter with the theme ID
-
custom_asset_filters = Module.new do
-
define_method :asset_url do |input|
-
if builder_theme_id
-
"/admin/builder/#{builder_theme_id}/#{input}"
-
else
-
"/assets/#{input}"
-
end
-
end
-
-
define_method :image_url do |input|
-
"/images/#{input}"
-
end
-
end
-
-
Liquid::Template.register_filter(custom_asset_filters)
-
Liquid::Template.register_filter(ContentFilters)
-
Liquid::Template.register_filter(DateFilters)
-
Liquid::Template.register_filter(StringFilters)
-
Liquid::Template.register_filter(ArrayFilters)
-
Liquid::Template.register_filter(UrlFilters)
-
Liquid::Template.register_filter(MetaFilters)
-
-
# Register custom tags
-
Liquid::Template.register_tag('section', SectionTag)
-
Liquid::Template.register_tag('paginate', PaginateTag)
-
Liquid::Template.register_tag('form', FormTag)
-
Liquid::Template.register_tag('comment_form', CommentFormTag)
-
Liquid::Template.register_tag('search_form', SearchFormTag)
-
end
-
-
# Asset filters
-
module AssetFilters
-
def asset_url(input)
-
# Return builder asset URL for preview
-
# This will be set by the renderer when registering filters
-
if @builder_theme_id
-
"/admin/builder/#{@builder_theme_id}/#{input}"
-
else
-
"/assets/#{input}"
-
end
-
end
-
-
def image_url(input)
-
# Return a placeholder URL for preview
-
"/images/#{input}"
-
end
-
-
def file_url(input)
-
# Return a placeholder URL for preview
-
"/files/#{input}"
-
end
-
-
def stylesheet_url(input)
-
"/assets/#{input}.css"
-
end
-
-
def script_url(input)
-
"/assets/#{input}.js"
-
end
-
end
-
-
# Content filters
-
module ContentFilters
-
def strip_html(input)
-
return '' unless input
-
ActionController::Base.helpers.strip_tags(input.to_s)
-
end
-
-
def truncate(input, length = 50, truncate_string = '...')
-
return '' unless input
-
input.to_s.length > length ? input.to_s[0, length] + truncate_string : input.to_s
-
end
-
-
def truncatewords(input, words = 15, truncate_string = '...')
-
return '' unless input
-
words_array = input.to_s.split
-
words_array.length > words ? words_array[0, words].join(' ') + truncate_string : input.to_s
-
end
-
-
def strip_newlines(input)
-
return '' unless input
-
input.to_s.gsub(/\r?\n/, ' ')
-
end
-
-
def newline_to_br(input)
-
return '' unless input
-
input.to_s.gsub(/\r?\n/, '<br>')
-
end
-
-
def escape_html(input)
-
return '' unless input
-
ERB::Util.html_escape(input.to_s)
-
end
-
-
def unescape_html(input)
-
return '' unless input
-
CGI.unescapeHTML(input.to_s)
-
end
-
-
def json(input)
-
return '{}' unless input
-
input.to_json
-
end
-
-
def xml_escape(input)
-
return '' unless input
-
input.to_s.gsub(/&/, '&').gsub(/</, '<').gsub(/>/, '>').gsub(/"/, '"').gsub(/'/, ''')
-
end
-
end
-
-
# Date filters
-
module DateFilters
-
def date(input, format = '%B %d, %Y')
-
return '' unless input
-
begin
-
date = case input
-
when String
-
Time.parse(input)
-
when Date, Time, DateTime
-
input
-
else
-
input.to_time
-
end
-
date.strftime(format)
-
rescue
-
input.to_s
-
end
-
end
-
-
def time(input, format = '%I:%M %p')
-
date(input, format)
-
end
-
-
def datetime(input, format = '%B %d, %Y at %I:%M %p')
-
date(input, format)
-
end
-
-
def time_ago(input)
-
return '' unless input
-
begin
-
time = case input
-
when String
-
Time.parse(input)
-
when Date, Time, DateTime
-
input
-
else
-
input.to_time
-
end
-
time_ago_in_words(time)
-
rescue
-
input.to_s
-
end
-
end
-
-
def time_ago_in_words(time)
-
distance = Time.current - time
-
case distance
-
when 0..1.minute
-
'just now'
-
when 1.minute..1.hour
-
"#{(distance / 1.minute).round} minutes ago"
-
when 1.hour..1.day
-
"#{(distance / 1.hour).round} hours ago"
-
when 1.day..1.week
-
"#{(distance / 1.day).round} days ago"
-
when 1.week..1.month
-
"#{(distance / 1.week).round} weeks ago"
-
when 1.month..1.year
-
"#{(distance / 1.month).round} months ago"
-
else
-
"#{(distance / 1.year).round} years ago"
-
end
-
end
-
end
-
-
# String filters
-
module StringFilters
-
def capitalize(input)
-
return '' unless input
-
input.to_s.capitalize
-
end
-
-
def upcase(input)
-
return '' unless input
-
input.to_s.upcase
-
end
-
-
def downcase(input)
-
return '' unless input
-
input.to_s.downcase
-
end
-
-
def capitalize_words(input)
-
return '' unless input
-
input.to_s.split.map(&:capitalize).join(' ')
-
end
-
-
def replace(input, string, replacement = '')
-
return '' unless input
-
input.to_s.gsub(string, replacement)
-
end
-
-
def remove(input, string)
-
return '' unless input
-
input.to_s.gsub(string, '')
-
end
-
-
def append(input, string)
-
return string unless input
-
input.to_s + string.to_s
-
end
-
-
def prepend(input, string)
-
return input unless string
-
string.to_s + input.to_s
-
end
-
-
def slice(input, start, length = 1)
-
return '' unless input
-
input.to_s[start.to_i, length.to_i] || ''
-
end
-
-
def size(input)
-
return 0 unless input
-
input.to_s.length
-
end
-
-
def lstrip(input)
-
return '' unless input
-
input.to_s.lstrip
-
end
-
-
def rstrip(input)
-
return '' unless input
-
input.to_s.rstrip
-
end
-
-
def strip(input)
-
return '' unless input
-
input.to_s.strip
-
end
-
end
-
-
# Array filters
-
module ArrayFilters
-
def join(input, glue = ' ')
-
return '' unless input
-
Array(input).join(glue)
-
end
-
-
def first(input)
-
return '' unless input
-
Array(input).first
-
end
-
-
def last(input)
-
return '' unless input
-
Array(input).last
-
end
-
-
def size(input)
-
return 0 unless input
-
Array(input).size
-
end
-
-
def sort(input, property = nil)
-
return [] unless input
-
array = Array(input)
-
if property
-
array.sort_by { |item| item.respond_to?(property) ? item.send(property) : item }
-
else
-
array.sort
-
end
-
end
-
-
def reverse(input)
-
return [] unless input
-
Array(input).reverse
-
end
-
-
def uniq(input)
-
return [] unless input
-
Array(input).uniq
-
end
-
-
def where(input, property, value)
-
return [] unless input
-
Array(input).select { |item| item.respond_to?(property) && item.send(property) == value }
-
end
-
-
def where_not(input, property, value)
-
return [] unless input
-
Array(input).reject { |item| item.respond_to?(property) && item.send(property) == value }
-
end
-
-
def limit(input, count)
-
return [] unless input
-
Array(input).first(count.to_i)
-
end
-
-
def offset(input, count)
-
return [] unless input
-
Array(input).drop(count.to_i)
-
end
-
end
-
-
# URL filters
-
module UrlFilters
-
def url_encode(input)
-
return '' unless input
-
ERB::Util.url_encode(input.to_s)
-
end
-
-
def url_decode(input)
-
return '' unless input
-
CGI.unescape(input.to_s)
-
end
-
-
def link_to(text, url, options = {})
-
return '' unless text && url
-
attributes = options.map { |k, v| "#{k}=\"#{v}\"" }.join(' ')
-
"<a href=\"#{url}\" #{attributes}>#{text}</a>"
-
end
-
-
def link_to_if(condition, text, url, options = {})
-
return text unless condition
-
link_to(text, url, options)
-
end
-
-
def link_to_unless(condition, text, url, options = {})
-
return text if condition
-
link_to(text, url, options)
-
end
-
end
-
-
# Meta filters
-
module MetaFilters
-
def meta(input, key)
-
return '' unless input
-
if input.respond_to?(:meta) && input.meta.is_a?(Hash)
-
input.meta[key.to_s] || ''
-
else
-
''
-
end
-
end
-
-
def has_meta(input, key)
-
return false unless input
-
if input.respond_to?(:meta) && input.meta.is_a?(Hash)
-
input.meta.key?(key.to_s)
-
else
-
false
-
end
-
end
-
-
def meta_keys(input)
-
return [] unless input
-
if input.respond_to?(:meta) && input.meta.is_a?(Hash)
-
input.meta.keys
-
else
-
[]
-
end
-
end
-
end
-
-
# Custom Liquid tags
-
class SectionTag < Liquid::Tag
-
def initialize(tag_name, markup, options)
-
super
-
@section_name = markup.strip.gsub(/['"]/, '')
-
end
-
-
def render(context)
-
# This would be handled by the main renderer
-
"<!-- Section: #{@section_name} -->"
-
end
-
end
-
-
class PaginateTag < Liquid::Tag
-
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
def render(context)
-
paginate = context['paginate']
-
return '' unless paginate
-
-
html = []
-
html << "<div class=\"pagination\">"
-
-
if paginate['current_page'] > 1
-
html << "<a href=\"?page=#{paginate['current_page'] - 1}\" class=\"prev\">Previous</a>"
-
end
-
-
(1..paginate['total_pages']).each do |page|
-
if page == paginate['current_page']
-
html << "<span class=\"current\">#{page}</span>"
-
else
-
html << "<a href=\"?page=#{page}\" class=\"page\">#{page}</a>"
-
end
-
end
-
-
if paginate['current_page'] < paginate['total_pages']
-
html << "<a href=\"?page=#{paginate['current_page'] + 1}\" class=\"next\">Next</a>"
-
end
-
-
html << "</div>"
-
html.join("\n")
-
end
-
end
-
-
class FormTag < Liquid::Tag
-
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
def render(context)
-
# Basic form rendering
-
"<form method=\"post\" class=\"liquid-form\">#{@markup}</form>"
-
end
-
end
-
-
class CommentFormTag < Liquid::Tag
-
def render(context)
-
post = context['post']
-
return '' unless post
-
-
html = []
-
html << "<form method=\"post\" action=\"/comments\" class=\"comment-form\">"
-
html << "<input type=\"hidden\" name=\"post_id\" value=\"#{post.id}\">"
-
html << "<div class=\"form-group\">"
-
html << "<label for=\"author_name\">Name:</label>"
-
html << "<input type=\"text\" name=\"author_name\" id=\"author_name\" required>"
-
html << "</div>"
-
html << "<div class=\"form-group\">"
-
html << "<label for=\"author_email\">Email:</label>"
-
html << "<input type=\"email\" name=\"author_email\" id=\"author_email\" required>"
-
html << "</div>"
-
html << "<div class=\"form-group\">"
-
html << "<label for=\"content\">Comment:</label>"
-
html << "<textarea name=\"content\" id=\"content\" required></textarea>"
-
html << "</div>"
-
html << "<button type=\"submit\">Submit Comment</button>"
-
html << "</form>"
-
html.join("\n")
-
end
-
end
-
-
class SearchFormTag < Liquid::Tag
-
def render(context)
-
html = []
-
html << "<form method=\"get\" action=\"/search\" class=\"search-form\">"
-
html << "<input type=\"text\" name=\"q\" placeholder=\"Search...\" value=\"#{context['search_query'] || ''}\">"
-
html << "<button type=\"submit\">Search</button>"
-
html << "</form>"
-
html.join("\n")
-
end
-
end
-
-
-
def default_layout
-
<<~LIQUID
-
<!DOCTYPE html>
-
<html lang="en">
-
<head>
-
<meta charset="UTF-8">
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<title>{{ page.title | default: site.title }}</title>
-
<meta name="description" content="{{ page.description | default: site.description }}">
-
<style>
-
{{ assets.css }}
-
</style>
-
</head>
-
<body>
-
{{ content_for_layout }}
-
<script>
-
{{ assets.js }}
-
</script>
-
</body>
-
</html>
-
LIQUID
-
end
-
end
-
class BuilderThemeService
-
attr_reader :builder_theme
-
-
def initialize(builder_theme)
-
@builder_theme = builder_theme
-
end
-
-
# Apply theme snapshot to the frontend
-
def apply_snapshot_to_frontend
-
return false unless builder_theme.published?
-
-
snapshot = builder_theme.builder_theme_snapshots.last
-
return false unless snapshot
-
-
# Update the active theme to use this snapshot
-
update_active_theme_settings(snapshot)
-
-
# Clear any relevant caches
-
clear_theme_caches
-
-
# Trigger frontend update notification
-
notify_frontend_update
-
-
true
-
end
-
-
# Create a new version from an existing theme
-
def create_version_from_theme(theme_name, user, label = nil)
-
# Get the current published version or create from base theme
-
base_version = BuilderTheme.current_for_theme(theme_name)
-
-
new_version = BuilderTheme.create_version(
-
theme_name,
-
user,
-
base_version,
-
label
-
)
-
-
# Copy files from base version or theme directory
-
if base_version
-
copy_files_from_version(base_version, new_version)
-
else
-
copy_files_from_theme_directory(theme_name, new_version)
-
end
-
-
new_version
-
end
-
-
# Export theme as a downloadable package
-
def export_theme_package
-
return nil unless builder_theme.published?
-
-
# Create a temporary directory for the export
-
temp_dir = Rails.root.join('tmp', 'theme_exports', "theme_#{builder_theme.id}_#{Time.current.to_i}")
-
FileUtils.mkdir_p(temp_dir)
-
-
begin
-
# Copy all theme files
-
builder_theme.builder_theme_files.each do |file|
-
file_path = temp_dir.join(file.path)
-
FileUtils.mkdir_p(file_path.dirname)
-
File.write(file_path, file.content)
-
end
-
-
# Create theme.json with metadata
-
theme_json = {
-
name: builder_theme.theme_name,
-
version: builder_theme.version_number.to_s,
-
description: "Exported from RailsPress Theme Builder",
-
author: builder_theme.user.email,
-
created_at: builder_theme.created_at.iso8601,
-
files: builder_theme.builder_theme_files.pluck(:path)
-
}
-
-
File.write(temp_dir.join('theme.json'), JSON.pretty_generate(theme_json))
-
-
# Create zip file
-
zip_path = temp_dir.parent.join("#{builder_theme.theme_name}_v#{builder_theme.version_number}.zip")
-
system("cd #{temp_dir} && zip -r #{zip_path} .")
-
-
zip_path if File.exist?(zip_path)
-
ensure
-
# Clean up temporary directory
-
FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
-
end
-
end
-
-
# Import theme from uploaded package
-
def self.import_theme_package(zip_file, user, theme_name = nil)
-
temp_dir = Rails.root.join('tmp', 'theme_imports', "import_#{Time.current.to_i}")
-
FileUtils.mkdir_p(temp_dir)
-
-
begin
-
# Extract zip file
-
system("unzip -q #{zip_file.path} -d #{temp_dir}")
-
-
# Read theme metadata
-
theme_json_path = temp_dir.join('theme.json')
-
if File.exist?(theme_json_path)
-
theme_data = JSON.parse(File.read(theme_json_path))
-
theme_name ||= theme_data['name']
-
end
-
-
# Create new builder theme version
-
builder_theme = BuilderTheme.create_version(theme_name, user, nil, "Imported theme")
-
-
# Copy files to builder theme
-
copy_files_from_directory(temp_dir, builder_theme)
-
-
builder_theme
-
ensure
-
FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
-
end
-
end
-
-
# Validate theme structure
-
def validate_theme_structure
-
errors = []
-
-
# Check for required files
-
required_files = ['templates/index.json', 'layout/theme.liquid']
-
required_files.each do |file|
-
unless builder_theme.get_file(file)
-
errors << "Missing required file: #{file}"
-
end
-
end
-
-
# Validate JSON files
-
builder_theme.builder_theme_files.json_files.each do |file|
-
begin
-
JSON.parse(file.content)
-
rescue JSON::ParserError => e
-
errors << "Invalid JSON in #{file.path}: #{e.message}"
-
end
-
end
-
-
# Validate Liquid files
-
builder_theme.builder_theme_files.liquid_files.each do |file|
-
# Basic Liquid syntax validation could be added here
-
# For now, we'll just check that the file isn't empty
-
if file.content.strip.empty?
-
errors << "Empty Liquid file: #{file.path}"
-
end
-
end
-
-
errors
-
end
-
-
private
-
-
def update_active_theme_settings(snapshot)
-
# Update the active theme's settings in the database
-
active_theme = Theme.active.first
-
return unless active_theme
-
-
# Merge snapshot settings with existing theme settings
-
current_settings = active_theme.settings || {}
-
snapshot_settings = snapshot.settings
-
-
# Update theme settings
-
active_theme.update!(settings: current_settings.merge(snapshot_settings))
-
-
# Store snapshot reference for rollback capability
-
Rails.cache.write("active_theme_snapshot_#{active_theme.name}", snapshot.id, expires_in: 1.week)
-
end
-
-
def clear_theme_caches
-
# Clear Rails view cache
-
ActionView::LookupContext::DetailsKey.clear
-
-
# Clear any custom theme caches
-
Rails.cache.delete_matched("theme_*")
-
-
# Clear asset cache if using asset pipeline
-
Rails.application.config.assets.version = Time.current.to_i.to_s if Rails.application.config.respond_to?(:assets)
-
end
-
-
def notify_frontend_update
-
# Broadcast to any connected frontend clients
-
ActionCable.server.broadcast(
-
'theme_updates',
-
{
-
type: 'theme_updated',
-
theme_name: builder_theme.theme_name,
-
timestamp: Time.current.to_i
-
}
-
)
-
end
-
-
def copy_files_from_version(source_version, target_version)
-
source_version.builder_theme_files.each do |file|
-
target_version.builder_theme_files.create!(
-
path: file.path,
-
content: file.content,
-
checksum: file.checksum,
-
file_size: file.file_size
-
)
-
end
-
end
-
-
def copy_files_from_theme_directory(theme_name, builder_theme)
-
theme_path = Rails.root.join('app', 'themes', theme_name)
-
return unless Dir.exist?(theme_path)
-
-
copy_files_recursive(theme_path, builder_theme, '')
-
end
-
-
def copy_files_recursive(directory, builder_theme, relative_path)
-
Dir.entries(directory).each do |entry|
-
next if entry.start_with?('.')
-
-
entry_path = File.join(directory, entry)
-
file_relative_path = relative_path.present? ? "#{relative_path}/#{entry}" : entry
-
-
if File.directory?(entry_path)
-
copy_files_recursive(entry_path, builder_theme, file_relative_path)
-
else
-
content = File.read(entry_path)
-
builder_theme.update_file(file_relative_path, content)
-
end
-
end
-
end
-
-
def self.copy_files_from_directory(directory, builder_theme)
-
Dir.glob(File.join(directory, '**', '*')).each do |file_path|
-
next if File.directory?(file_path)
-
-
relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(directory)).to_s
-
content = File.read(file_path)
-
-
builder_theme.update_file(relative_path, content)
-
end
-
end
-
end
-
-
# Content Analytics Service - Medium-like analytics for posts and pages
-
class ContentAnalyticsService
-
include AnalyticsHelper
-
-
# Get comprehensive analytics for a specific post
-
def self.post_analytics(post_id, period: :month)
-
post = Post.find(post_id)
-
range = period_range(period)
-
pageviews = Pageview.where(visited_at: range, post_id: post_id).non_bot.consented_only
-
-
readers = pageviews.where(is_reader: true) # Medium-like readers (30+ seconds)
-
-
{
-
# Basic metrics
-
total_views: pageviews.count,
-
unique_readers: pageviews.distinct.count(:session_id),
-
medium_readers: readers.count, # Users who spent 30+ seconds (Medium definition)
-
reader_conversion_rate: readers.count.to_f / [pageviews.count, 1].max * 100,
-
returning_readers: pageviews.where(returning_visitor: true).distinct.count(:session_id),
-
-
# Engagement metrics
-
avg_reading_time: pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
-
avg_engagement_score: pageviews.where.not(engagement_score: nil).average(:engagement_score)&.to_f || 0.0,
-
avg_scroll_depth: pageviews.where.not(scroll_depth: nil).average(:scroll_depth)&.to_i || 0,
-
avg_completion_rate: pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
-
avg_time_on_page: pageviews.where.not(time_on_page: nil).average(:time_on_page)&.to_i || 0,
-
-
# Reader behavior (Medium-like)
-
readers_who_scrolled_to_bottom: readers.where(scroll_depth: 100).count,
-
readers_who_spent_time: readers.where('time_on_page > ?', 30).count,
-
readers_with_exit_intent: readers.where(exit_intent: true).count,
-
-
# Demographics (focus on actual readers)
-
readers_by_country: readers.where.not(country_code: nil)
-
.group(:country_code)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.to_h,
-
-
readers_by_device: readers.group(:device).count(:id).sort_by { |_, count| -count }.to_h,
-
readers_by_browser: readers.group(:browser).count(:id).sort_by { |_, count| -count }.to_h,
-
-
# Traffic sources
-
traffic_sources: pageviews.where.not(referrer: [nil, ''])
-
.group(:referrer)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.to_h,
-
-
# Time-based analytics
-
views_by_hour: pageviews.group("strftime('%H', visited_at)")
-
.count(:id)
-
.transform_keys(&:to_i)
-
.sort.to_h,
-
-
views_by_day: pageviews.group("date(visited_at)")
-
.count(:id)
-
.sort_by { |date, _| Date.parse(date) }
-
.to_h,
-
-
# Content performance
-
reading_time_estimate: estimate_reading_time(post),
-
engagement_score: calculate_engagement_score(pageviews),
-
-
# Post metadata
-
post: {
-
id: post.id,
-
title: post.title,
-
slug: post.slug,
-
published_at: post.published_at,
-
word_count: post.word_count,
-
reading_time: post.reading_time
-
}
-
}
-
end
-
-
# Get comprehensive analytics for a specific page
-
def self.page_analytics(page_id, period: :month)
-
page = Page.find(page_id)
-
range = period_range(period)
-
pageviews = Pageview.where(visited_at: range, page_id: page_id).non_bot.consented_only
-
-
{
-
# Basic metrics
-
total_views: pageviews.count,
-
unique_visitors: pageviews.distinct.count(:session_id),
-
returning_visitors: pageviews.where(returning_visitor: true).distinct.count(:session_id),
-
-
# Engagement metrics
-
avg_reading_time: pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
-
avg_scroll_depth: pageviews.where.not(scroll_depth: nil).average(:scroll_depth)&.to_i || 0,
-
avg_completion_rate: pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
-
avg_time_on_page: pageviews.where.not(time_on_page: nil).average(:time_on_page)&.to_i || 0,
-
-
# Visitor behavior
-
visitors_who_scrolled_to_bottom: pageviews.where(scroll_depth: 100).count,
-
visitors_who_spent_time: pageviews.where('time_on_page > ?', 30).count,
-
visitors_with_exit_intent: pageviews.where(exit_intent: true).count,
-
-
# Demographics
-
visitors_by_country: pageviews.where.not(country_code: nil)
-
.group(:country_code)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.to_h,
-
-
visitors_by_device: pageviews.group(:device).count(:id).sort_by { |_, count| -count }.to_h,
-
visitors_by_browser: pageviews.group(:browser).count(:id).sort_by { |_, count| -count }.to_h,
-
-
# Traffic sources
-
traffic_sources: pageviews.where.not(referrer: [nil, ''])
-
.group(:referrer)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(10)
-
.to_h,
-
-
# Time-based analytics
-
views_by_hour: pageviews.group("strftime('%H', visited_at)")
-
.count(:id)
-
.transform_keys(&:to_i)
-
.sort.to_h,
-
-
views_by_day: pageviews.group("date(visited_at)")
-
.count(:id)
-
.sort_by { |date, _| Date.parse(date) }
-
.to_h,
-
-
# Content performance
-
engagement_score: calculate_engagement_score(pageviews),
-
-
# Page metadata
-
page: {
-
id: page.id,
-
title: page.title,
-
slug: page.slug,
-
published_at: page.published_at,
-
word_count: page.word_count
-
}
-
}
-
end
-
-
# Get top performing content
-
def self.top_performing_content(period: :month, limit: 10)
-
range = period_range(period)
-
-
# Top posts
-
top_posts = Pageview.where(visited_at: range)
-
.where.not(post_id: nil)
-
.non_bot
-
.consented_only
-
.group(:post_id)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(limit)
-
.map do |post_id, views|
-
post = Post.find_by(id: post_id)
-
next unless post
-
-
post_pageviews = Pageview.where(visited_at: range, post_id: post_id).non_bot.consented_only
-
-
{
-
id: post.id,
-
title: post.title,
-
slug: post.slug,
-
published_at: post.published_at,
-
views: views,
-
unique_readers: post_pageviews.distinct.count(:session_id),
-
avg_reading_time: post_pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
-
avg_completion_rate: post_pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
-
engagement_score: calculate_engagement_score(post_pageviews),
-
url: Rails.application.routes.url_helpers.post_path(post)
-
}
-
end.compact
-
-
# Top pages
-
top_pages = Pageview.where(visited_at: range)
-
.where.not(page_id: nil)
-
.non_bot
-
.consented_only
-
.group(:page_id)
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(limit)
-
.map do |page_id, views|
-
page = Page.find_by(id: page_id)
-
next unless page
-
-
page_pageviews = Pageview.where(visited_at: range, page_id: page_id).non_bot.consented_only
-
-
{
-
id: page.id,
-
title: page.title,
-
slug: page.slug,
-
published_at: page.published_at,
-
views: views,
-
unique_visitors: page_pageviews.distinct.count(:session_id),
-
avg_reading_time: page_pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
-
avg_completion_rate: page_pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
-
engagement_score: calculate_engagement_score(page_pageviews),
-
url: Rails.application.routes.url_helpers.page_path(page)
-
}
-
end.compact
-
-
{
-
top_posts: top_posts,
-
top_pages: top_pages,
-
period: period,
-
generated_at: Time.current
-
}
-
end
-
-
# Get reader engagement insights
-
def self.reader_engagement_insights(period: :month)
-
range = period_range(period)
-
pageviews = Pageview.where(visited_at: range).non_bot.consented_only
-
-
{
-
# Reading behavior
-
avg_reading_time: pageviews.where.not(reading_time: nil).average(:reading_time)&.to_i || 0,
-
avg_scroll_depth: pageviews.where.not(scroll_depth: nil).average(:scroll_depth)&.to_i || 0,
-
avg_completion_rate: pageviews.where.not(completion_rate: nil).average(:completion_rate)&.to_f || 0.0,
-
-
# Reader segments
-
quick_readers: pageviews.where('reading_time < ?', 30).count,
-
engaged_readers: pageviews.where('reading_time BETWEEN ? AND ?', 30, 300).count,
-
deep_readers: pageviews.where('reading_time > ?', 300).count,
-
-
# Engagement levels
-
low_engagement: pageviews.where('completion_rate < ?', 0.25).count,
-
medium_engagement: pageviews.where('completion_rate BETWEEN ? AND ?', 0.25, 0.75).count,
-
high_engagement: pageviews.where('completion_rate > ?', 0.75).count,
-
-
# Scroll behavior
-
readers_who_scrolled_25: pageviews.where('scroll_depth >= ?', 25).count,
-
readers_who_scrolled_50: pageviews.where('scroll_depth >= ?', 50).count,
-
readers_who_scrolled_75: pageviews.where('scroll_depth >= ?', 75).count,
-
readers_who_scrolled_100: pageviews.where('scroll_depth >= ?', 100).count,
-
-
# Time patterns
-
peak_reading_hours: pageviews.group("strftime('%H', visited_at)")
-
.count(:id)
-
.sort_by { |_, count| -count }
-
.first(5)
-
.to_h,
-
-
# Content preferences
-
preferred_content_length: analyze_content_length_preferences(pageviews),
-
preferred_device_types: pageviews.group(:device).count(:id).sort_by { |_, count| -count }.to_h
-
}
-
end
-
-
private
-
-
def self.period_range(period)
-
case period.to_sym
-
when :today
-
Time.current.beginning_of_day..Time.current.end_of_day
-
when :week
-
1.week.ago..Time.current
-
when :month
-
1.month.ago..Time.current
-
when :year
-
1.year.ago..Time.current
-
else
-
1.month.ago..Time.current
-
end
-
end
-
-
def self.estimate_reading_time(content)
-
return 0 unless content.respond_to?(:content)
-
-
# Estimate reading time based on word count (average 200 words per minute)
-
word_count = content.content&.gsub(/<[^>]*>/, '')&.split&.count || 0
-
(word_count / 200.0).ceil
-
end
-
-
def self.calculate_engagement_score(pageviews)
-
return 0 if pageviews.empty?
-
-
# Calculate engagement score based on multiple factors
-
avg_completion = pageviews.where.not(completion_rate: nil).average(:completion_rate) || 0
-
avg_scroll_depth = pageviews.where.not(scroll_depth: nil).average(:scroll_depth) || 0
-
avg_time_on_page = pageviews.where.not(time_on_page: nil).average(:time_on_page) || 0
-
-
# Weighted score: completion rate (40%), scroll depth (30%), time on page (30%)
-
engagement_score = (avg_completion * 0.4) + (avg_scroll_depth / 100.0 * 0.3) + (avg_time_on_page / 300.0 * 0.3)
-
-
# Normalize to 0-100 scale
-
(engagement_score * 100).round(1)
-
end
-
-
def self.analyze_content_length_preferences(pageviews)
-
# Analyze what content lengths perform best
-
content_performance = {}
-
-
pageviews.includes(:post, :page).each do |pageview|
-
content = pageview.post || pageview.page
-
next unless content
-
-
word_count = content.content&.gsub(/<[^>]*>/, '')&.split&.count || 0
-
length_category = case word_count
-
when 0..500 then 'short'
-
when 501..1500 then 'medium'
-
when 1501..3000 then 'long'
-
else 'very_long'
-
end
-
-
content_performance[length_category] ||= { views: 0, engagement: 0 }
-
content_performance[length_category][:views] += 1
-
content_performance[length_category][:engagement] += pageview.completion_rate || 0
-
end
-
-
# Calculate average engagement per category
-
content_performance.transform_values do |data|
-
{
-
views: data[:views],
-
avg_engagement: data[:engagement] / data[:views].to_f
-
}
-
end
-
end
-
end
-
class DocumentationSyncService
-
include ActiveModel::Model
-
-
attr_accessor :source_url, :force_update
-
-
def initialize(source_url: nil, force_update: false)
-
@source_url = source_url || Rails.application.routes.url_helpers.root_url
-
@force_update = force_update
-
end
-
-
# Sync documentation from external source
-
def sync_from_source
-
return false unless source_url.present?
-
-
begin
-
# Fetch documentation from source
-
response = HTTParty.get("#{source_url}/api/documentation", timeout: 30)
-
return false unless response.success?
-
-
docs_data = response.parsed_response
-
-
# Update theme documentation
-
if docs_data['theme_development_docs'].present?
-
update_site_setting('theme_development_docs', docs_data['theme_development_docs'])
-
end
-
-
# Update plugin documentation
-
if docs_data['plugin_development_docs'].present?
-
update_site_setting('plugin_development_docs', docs_data['plugin_development_docs'])
-
end
-
-
# Update sync timestamp
-
update_site_setting('docs_last_synced_at', Time.current)
-
-
Rails.logger.info "Documentation synced successfully from #{source_url}"
-
true
-
-
rescue => e
-
Rails.logger.error "Failed to sync documentation: #{e.message}"
-
false
-
end
-
end
-
-
# Check if sync is needed
-
def sync_needed?
-
return true if force_update
-
-
last_sync = get_site_setting('docs_last_synced_at')
-
return true if last_sync.nil?
-
-
# Sync if older than 24 hours
-
last_sync < 24.hours.ago
-
end
-
-
# Auto-sync if needed
-
def auto_sync
-
return false unless sync_needed?
-
sync_from_source
-
end
-
-
private
-
-
def update_site_setting(key, value)
-
SiteSetting.set(key, value, 'text')
-
end
-
-
def get_site_setting(key)
-
SiteSetting.get(key)
-
end
-
end
-
-
-
class FrontendRendererService
-
attr_reader :published_version, :builder_renderer
-
-
def initialize(published_version, builder_theme_id = nil)
-
@published_version = published_version
-
@builder_theme_id = builder_theme_id
-
# Create a mock BuilderTheme for the existing BuilderLiquidRenderer
-
@builder_theme = create_mock_builder_theme
-
Rails.logger.info "Created mock builder theme: #{@builder_theme.class}"
-
@builder_renderer = BuilderLiquidRenderer.new(@builder_theme)
-
Rails.logger.info "Created BuilderLiquidRenderer"
-
end
-
-
# Render a template with all sections, header, footer, etc.
-
def render_template(template_name, context = {})
-
# Use the existing BuilderLiquidRenderer
-
html = @builder_renderer.render_template(template_name, context)
-
-
# Replace asset URLs with embedded content for preview
-
html = replace_asset_urls_with_content(html)
-
-
html
-
rescue => e
-
Rails.logger.error "FrontendRendererService error: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
"<div class='error'>FrontendRendererService Error: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
-
end
-
-
# Get CSS and JS assets including all sections
-
def assets
-
# Use the existing BuilderLiquidRenderer's assets method
-
@builder_renderer.assets
-
end
-
-
private
-
-
def replace_asset_urls_with_content(html)
-
# Get assets from the renderer
-
assets = @builder_renderer.assets
-
-
# Replace CSS link tags with embedded styles
-
html = html.gsub(/<link[^>]*href="[^"]*\/theme\.css"[^>]*>/) do |match|
-
if assets[:css].present?
-
"<style>#{assets[:css]}</style>"
-
else
-
match # Keep original if no CSS
-
end
-
end
-
-
# Replace JS script tags with embedded scripts
-
html = html.gsub(/<script[^>]*src="[^"]*\/theme\.js"[^>]*><\/script>/) do |match|
-
if assets[:js].present?
-
"<script>#{assets[:js]}</script>"
-
else
-
match # Keep original if no JS
-
end
-
end
-
-
html
-
rescue => e
-
Rails.logger.error "Error in replace_asset_urls_with_content: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
html # Return original HTML if there's an error
-
end
-
-
def create_mock_builder_theme
-
# Create a mock BuilderTheme object that delegates to PublishedThemeFile
-
mock_theme = Object.new
-
-
# Define methods that BuilderLiquidRenderer expects
-
def mock_theme.get_rendered_file(template_name)
-
# Return the template data from PublishedThemeFile
-
template_file = @published_version.published_theme_files.find_by(file_path: "templates/#{template_name}.json")
-
return nil unless template_file
-
-
template_content = JSON.parse(template_file.content)
-
-
# Get layout file
-
layout_file = @published_version.published_theme_files.find_by(file_path: 'layout/theme.liquid')
-
layout_content = layout_file&.content || FrontendRendererService.default_layout
-
-
# Build page sections from template data
-
page_sections = []
-
template_content['order']&.each_with_index do |section_id, index|
-
section_config = template_content['sections'][section_id]
-
next unless section_config
-
-
# Create a mock section object
-
section = Object.new
-
def section.section_id
-
@section_id
-
end
-
def section.section_type
-
@section_type
-
end
-
def section.settings
-
@settings
-
end
-
def section.position
-
@position
-
end
-
-
section.instance_variable_set(:@section_id, section_id)
-
section.instance_variable_set(:@section_type, section_config['type'])
-
section.instance_variable_set(:@settings, section_config['settings'] || {})
-
section.instance_variable_set(:@position, index)
-
-
page_sections << section
-
end
-
-
{
-
template_name: template_name,
-
template_content: template_content,
-
layout_content: layout_content,
-
theme_settings: {},
-
page_sections: page_sections
-
}
-
end
-
-
# Store the published_version and builder_theme_id for access in methods
-
mock_theme.instance_variable_set(:@published_version, published_version)
-
mock_theme.instance_variable_set(:@builder_theme_id, @builder_theme_id)
-
-
# Add other methods that might be needed
-
def mock_theme.theme_name
-
@published_version.theme.name.underscore
-
end
-
-
def mock_theme.id
-
@builder_theme_id || @published_version.id
-
end
-
-
mock_theme
-
end
-
-
def self.default_layout
-
<<~HTML
-
<!DOCTYPE html>
-
<html lang="en">
-
<head>
-
<meta charset="UTF-8">
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<title>{{ page.title | default: site.title }}</title>
-
</head>
-
<body>
-
{{ content_for_layout }}
-
</body>
-
</html>
-
HTML
-
end
-
end
-
class FrontendThemeRenderer
-
class << self
-
def render_template(template_name, context = {})
-
# Get the active theme
-
active_theme = Theme.active.first
-
return render_error('No active theme found') unless active_theme
-
-
# Ensure PublishedThemeVersion exists
-
published_version = ensure_published_version_exists(active_theme)
-
return render_error('Failed to create published theme version') unless published_version
-
-
# Use FrontendRendererService to render
-
renderer = FrontendRendererService.new(published_version)
-
-
begin
-
renderer.render_template(template_name, context)
-
rescue => e
-
Rails.logger.error "Frontend rendering error: #{e.message}"
-
render_error("Rendering error: #{e.message}")
-
end
-
end
-
-
def load_assets
-
# Get the active theme
-
active_theme = Theme.active.first
-
return { css: '', js: '' } unless active_theme
-
-
# Ensure PublishedThemeVersion exists
-
published_version = ensure_published_version_exists(active_theme)
-
return { css: '', js: '' } unless published_version
-
-
# Use FrontendRendererService to get assets
-
renderer = FrontendRendererService.new(published_version)
-
renderer.assets
-
end
-
-
def current_theme_name
-
active_theme = Theme.active.first
-
active_theme&.name&.underscore || 'default'
-
end
-
-
private
-
-
def ensure_published_version_exists(theme)
-
# Check if we already have a PublishedThemeVersion for this theme
-
published_version = PublishedThemeVersion.where(theme: theme).latest.first
-
-
if published_version
-
Rails.logger.debug "Using existing PublishedThemeVersion #{published_version.id} for theme #{theme.name}"
-
return published_version
-
end
-
-
Rails.logger.info "No PublishedThemeVersion found for #{theme.name}, creating initial version..."
-
-
# Create initial PublishedThemeVersion
-
published_version = PublishedThemeVersion.create!(
-
theme: theme,
-
version_number: 1,
-
published_at: Time.current,
-
published_by: User.first, # TODO: Use system user or current user if available
-
tenant: theme.tenant
-
)
-
-
# Copy all files from ThemeVersion to PublishedThemeFile
-
theme_version = ThemeVersion.for_theme(theme.name).live.first
-
-
if theme_version && theme_version.theme_files.any?
-
theme_version.theme_files.each do |theme_file|
-
# Use the theme file's content directly
-
content = theme_file.current_content
-
next unless content
-
-
# Use the file_path as is (it should already be relative)
-
relative_path = theme_file.file_path
-
-
PublishedThemeFile.create!(
-
published_theme_version: published_version,
-
file_path: relative_path,
-
file_type: theme_file.file_type,
-
content: content,
-
checksum: Digest::MD5.hexdigest(content)
-
)
-
end
-
-
Rails.logger.info "Created initial PublishedThemeVersion #{published_version.id} with #{published_version.published_theme_files.count} files"
-
else
-
Rails.logger.warn "No theme files found for #{theme.name}"
-
end
-
-
published_version
-
rescue => e
-
Rails.logger.error "Failed to create PublishedThemeVersion for #{theme.name}: #{e.message}"
-
nil
-
end
-
-
def render_error(message)
-
<<~HTML
-
<!DOCTYPE html>
-
<html lang="en">
-
<head>
-
<meta charset="UTF-8">
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<title>Error - RailsPress</title>
-
<style>
-
body { font-family: system-ui, sans-serif; margin: 0; padding: 2rem; background: #f5f5f5; }
-
.error-container { max-width: 600px; margin: 0 auto; background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
-
.error-title { color: #dc2626; margin-bottom: 1rem; }
-
.error-message { color: #374151; line-height: 1.6; }
-
</style>
-
</head>
-
<body>
-
<div class="error-container">
-
<h1 class="error-title">Theme Error</h1>
-
<p class="error-message">#{message}</p>
-
</div>
-
</body>
-
</html>
-
HTML
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
class GdprComplianceService
-
include ActiveSupport::Benchmarkable
-
-
# Data subject rights under GDPR
-
DATA_SUBJECT_RIGHTS = %w[
-
right_to_be_informed
-
right_of_access
-
right_to_rectification
-
right_to_erasure
-
right_to_restrict_processing
-
right_to_data_portability
-
right_to_object
-
rights_related_to_automated_decision_making
-
].freeze
-
-
# Legal basis for processing under GDPR
-
LEGAL_BASIS = %w[
-
consent
-
contract
-
legal_obligation
-
vital_interests
-
public_task
-
legitimate_interests
-
].freeze
-
-
# Data categories we collect
-
DATA_CATEGORIES = %w[
-
identity_data
-
contact_data
-
technical_data
-
usage_data
-
marketing_data
-
analytics_data
-
geolocation_data
-
].freeze
-
-
class << self
-
# Check if GDPR applies to this request
-
def gdpr_applies?(request)
-
# GDPR applies to EU residents and EU data subjects
-
eu_countries = %w[AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE]
-
-
# Check if user is in EU based on IP geolocation
-
country_code = get_country_from_request(request)
-
eu_countries.include?(country_code)
-
rescue
-
# If we can't determine location, assume GDPR applies for safety
-
true
-
end
-
-
# Get country code from request
-
def get_country_from_request(request)
-
# Try to get country from analytics data first
-
session_id = request.session[:analytics_session_id]
-
if session_id
-
recent_pageview = Pageview.where(session_id: session_id)
-
.where('visited_at >= ?', 1.hour.ago)
-
.where.not(country_code: nil)
-
.first
-
return recent_pageview.country_code if recent_pageview
-
end
-
-
# Fallback to IP geolocation
-
GeolocationService.lookup_ip(request.ip)&.dig(:country_code)
-
rescue
-
nil
-
end
-
-
# Check if user has given valid consent
-
def has_valid_consent?(session_id, consent_type = 'analytics')
-
return true unless SiteSetting.get('analytics_require_consent', true)
-
-
# Check if consent is stored in session
-
consent_key = "analytics_consent_#{consent_type}"
-
Rails.cache.read("consent:#{session_id}:#{consent_key}") == true
-
rescue
-
false
-
end
-
-
# Store user consent
-
def store_consent(session_id, consent_data)
-
consent_data.each do |consent_type, granted|
-
consent_key = "analytics_consent_#{consent_type}"
-
Rails.cache.write("consent:#{session_id}:#{consent_key}", granted, expires_in: 1.year)
-
end
-
-
# Log consent for audit trail
-
log_consent_event(session_id, consent_data)
-
rescue => e
-
Rails.logger.error "Failed to store consent: #{e.message}"
-
end
-
-
# Log consent event for audit trail
-
def log_consent_event(session_id, consent_data)
-
AnalyticsEvent.create!(
-
event_name: 'gdpr_consent_updated',
-
properties: {
-
consent_data: consent_data,
-
legal_basis: 'consent',
-
data_categories: DATA_CATEGORIES,
-
gdpr_compliant: true
-
},
-
session_id: session_id,
-
tenant: ActsAsTenant.current_tenant || Tenant.first
-
)
-
rescue => e
-
Rails.logger.error "Failed to log consent event: #{e.message}"
-
end
-
-
# Handle data subject access request
-
def handle_data_access_request(session_id, request_data = {})
-
# Collect all data related to this session/user
-
data = {
-
pageviews: collect_pageview_data(session_id),
-
events: collect_event_data(session_id),
-
consent_history: collect_consent_history(session_id),
-
metadata: {
-
request_date: Time.current,
-
data_categories: DATA_CATEGORIES,
-
retention_period: SiteSetting.get('analytics_data_retention_days', 365),
-
legal_basis: 'consent'
-
}
-
}
-
-
# Log the access request
-
log_data_subject_request(session_id, 'access', request_data)
-
-
data
-
rescue => e
-
Rails.logger.error "Failed to handle data access request: #{e.message}"
-
{ error: e.message }
-
end
-
-
# Handle data deletion request
-
def handle_data_deletion_request(session_id, request_data = {})
-
deleted_count = 0
-
-
# Delete pageviews
-
pageview_count = Pageview.where(session_id: session_id).count
-
Pageview.where(session_id: session_id).delete_all
-
deleted_count += pageview_count
-
-
# Delete analytics events
-
event_count = AnalyticsEvent.where(session_id: session_id).count
-
AnalyticsEvent.where(session_id: session_id).delete_all
-
deleted_count += event_count
-
-
# Clear consent data
-
clear_consent_data(session_id)
-
-
# Log the deletion request
-
log_data_subject_request(session_id, 'deletion', request_data.merge(deleted_records: deleted_count))
-
-
{ deleted_records: deleted_count, success: true }
-
rescue => e
-
Rails.logger.error "Failed to handle data deletion request: #{e.message}"
-
{ error: e.message }
-
end
-
-
# Handle data portability request
-
def handle_data_portability_request(session_id, request_data = {})
-
# Collect data in portable format
-
data = handle_data_access_request(session_id, request_data)
-
-
# Convert to JSON format for portability
-
portable_data = {
-
export_date: Time.current.iso8601,
-
data_subject_id: session_id,
-
data_categories: DATA_CATEGORIES,
-
legal_basis: 'consent',
-
data: data
-
}
-
-
# Log the portability request
-
log_data_subject_request(session_id, 'portability', request_data)
-
-
portable_data
-
rescue => e
-
Rails.logger.error "Failed to handle data portability request: #{e.message}"
-
{ error: e.message }
-
end
-
-
# Collect pageview data for data subject
-
def collect_pageview_data(session_id)
-
Pageview.where(session_id: session_id).map do |pageview|
-
{
-
id: pageview.id,
-
path: pageview.path,
-
title: pageview.title,
-
visited_at: pageview.visited_at.iso8601,
-
referrer: pageview.referrer,
-
user_agent: pageview.user_agent,
-
country: pageview.country_name,
-
city: pageview.city,
-
device: pageview.device,
-
browser: pageview.browser,
-
reading_time: pageview.reading_time,
-
engagement_score: pageview.engagement_score,
-
is_reader: pageview.is_reader
-
}
-
end
-
end
-
-
# Collect event data for data subject
-
def collect_event_data(session_id)
-
AnalyticsEvent.where(session_id: session_id).map do |event|
-
{
-
id: event.id,
-
event_name: event.event_name,
-
properties: event.properties,
-
created_at: event.created_at.iso8601
-
}
-
end
-
end
-
-
# Collect consent history for data subject
-
def collect_consent_history(session_id)
-
# Get consent events from analytics
-
consent_events = AnalyticsEvent.where(session_id: session_id)
-
.where(event_name: 'gdpr_arnalytics_consent_updated')
-
.order(:created_at)
-
-
consent_events.map do |event|
-
{
-
event_id: event.id,
-
consent_data: event.properties['consent_data'],
-
timestamp: event.created_at.iso8601,
-
legal_basis: event.properties['legal_basis']
-
}
-
end
-
end
-
-
# Clear consent data for data subject
-
def clear_consent_data(session_id)
-
# Clear all consent cache entries
-
consent_types = %w[analytics marketing essential]
-
consent_types.each do |consent_type|
-
consent_key = "analytics_consent_#{consent_type}"
-
Rails.cache.delete("consent:#{session_id}:#{consent_key}")
-
end
-
end
-
-
# Log data subject request for audit trail
-
def log_data_subject_request(session_id, request_type, request_data)
-
AnalyticsEvent.create!(
-
event_name: "gdpr_data_subject_request_#{request_type}",
-
properties: {
-
request_type: request_type,
-
request_data: request_data,
-
legal_basis: 'legal_obligation',
-
gdpr_compliant: true,
-
data_categories: DATA_CATEGORIES
-
},
-
session_id: session_id,
-
tenant: ActsAsTenant.current_tenant
-
)
-
rescue => e
-
Rails.logger.error "Failed to log data subject request: #{e.message}"
-
end
-
-
# Check if data processing is lawful
-
def is_processing_lawful?(purpose, legal_basis, consent_given = false)
-
case legal_basis
-
when 'consent'
-
consent_given
-
when 'legitimate_interests'
-
legitimate_interests_assessment(purpose)
-
when 'contract'
-
contract_processing_assessment(purpose)
-
when 'legal_obligation'
-
legal_obligation_assessment(purpose)
-
else
-
false
-
end
-
end
-
-
# Assess legitimate interests
-
def legitimate_interests_assessment(purpose)
-
legitimate_purposes = %w[
-
analytics
-
security
-
fraud_prevention
-
service_improvement
-
performance_monitoring
-
]
-
-
legitimate_purposes.include?(purpose)
-
end
-
-
# Assess contract processing
-
def contract_processing_assessment(purpose)
-
contract_purposes = %w[
-
user_authentication
-
service_delivery
-
payment_processing
-
account_management
-
]
-
-
contract_purposes.include?(purpose)
-
end
-
-
# Assess legal obligation
-
def legal_obligation_assessment(purpose)
-
legal_purposes = %w[
-
tax_compliance
-
audit_requirements
-
regulatory_reporting
-
law_enforcement
-
]
-
-
legal_purposes.include?(purpose)
-
end
-
-
# Get privacy policy information
-
def get_privacy_policy_info
-
{
-
data_controller: SiteSetting.get('data_controller_name', 'RailsPress'),
-
data_controller_email: SiteSetting.get('data_controller_email', 'privacy@railspress.com'),
-
dpo_email: SiteSetting.get('dpo_email', 'dpo@railspress.com'),
-
data_categories: DATA_CATEGORIES,
-
legal_basis: 'consent',
-
retention_period: SiteSetting.get('analytics_data_retention_days', 365),
-
data_subject_rights: DATA_SUBJECT_RIGHTS,
-
third_party_sharing: get_third_party_sharing_info,
-
data_transfers: get_data_transfer_info
-
}
-
rescue => e
-
Rails.logger.error "Failed to get privacy policy info: #{e.message}"
-
{
-
data_controller: 'RailsPress',
-
data_controller_email: 'privacy@railspress.com',
-
dpo_email: 'dpo@railspress.com',
-
data_categories: DATA_CATEGORIES,
-
legal_basis: 'consent',
-
retention_period: 365,
-
data_subject_rights: DATA_SUBJECT_RIGHTS,
-
third_party_sharing: {},
-
data_transfers: {}
-
}
-
end
-
-
# Get third party sharing information
-
def get_third_party_sharing_info
-
{
-
google_analytics: {
-
purpose: 'analytics',
-
data_categories: %w[usage_data technical_data],
-
legal_basis: 'consent',
-
retention_period: 26 # months
-
},
-
maxmind: {
-
purpose: 'geolocation',
-
data_categories: %w[technical_data geolocation_data],
-
legal_basis: 'legitimate_interests',
-
retention_period: 365 # days
-
}
-
}
-
end
-
-
# Get data transfer information
-
def get_data_transfer_info
-
{
-
adequacy_decision: false,
-
safeguards: %w[standard_contractual_clauses],
-
transfers_to: %w[United_States],
-
transfer_purpose: 'analytics_and_geolocation'
-
}
-
end
-
-
# Perform data protection impact assessment
-
def perform_dpia(processing_activity)
-
{
-
processing_activity: processing_activity,
-
risk_level: assess_risk_level(processing_activity),
-
mitigation_measures: get_mitigation_measures(processing_activity),
-
assessment_date: Time.current,
-
assessor: 'RailsPress DPO'
-
}
-
end
-
-
# Assess risk level
-
def assess_risk_level(processing_activity)
-
high_risk_activities = %w[
-
large_scale_processing
-
systematic_monitoring
-
special_category_data
-
automated_decision_making
-
]
-
-
if high_risk_activities.any? { |activity| processing_activity.include?(activity) }
-
'high'
-
else
-
'medium'
-
end
-
end
-
-
# Get mitigation measures
-
def get_mitigation_measures(processing_activity)
-
measures = [
-
'data_minimization',
-
'purpose_limitation',
-
'storage_limitation',
-
'technical_and_organizational_measures',
-
'privacy_by_design',
-
'data_protection_by_default'
-
]
-
-
if processing_activity.include?('large_scale_processing')
-
measures += ['data_protection_impact_assessment', 'prior_consultation']
-
end
-
-
measures
-
end
-
-
# Check if processing is necessary and proportionate
-
def is_processing_necessary_and_proportionate?(purpose, data_categories, legal_basis)
-
# Check necessity
-
necessary = is_processing_necessary?(purpose, data_categories)
-
-
# Check proportionality
-
proportionate = is_processing_proportionate?(purpose, data_categories, legal_basis)
-
-
necessary && proportionate
-
end
-
-
# Check if processing is necessary
-
def is_processing_necessary?(purpose, data_categories)
-
case purpose
-
when 'analytics'
-
data_categories.include?('usage_data') && data_categories.include?('technical_data')
-
when 'geolocation'
-
data_categories.include?('geolocation_data')
-
when 'security'
-
data_categories.include?('technical_data')
-
else
-
false
-
end
-
end
-
-
# Check if processing is proportionate
-
def is_processing_proportionate?(purpose, data_categories, legal_basis)
-
# Check if we're collecting only what's needed
-
case purpose
-
when 'analytics'
-
data_categories.size <= 3 && legal_basis == 'consent'
-
when 'geolocation'
-
data_categories.size <= 2 && legal_basis == 'legitimate_interests'
-
else
-
data_categories.size <= 1
-
end
-
end
-
end
-
end
-
class GdprService
-
include Rails.application.routes.url_helpers
-
-
class << self
-
# Create a personal data export request
-
def create_export_request(user, requested_by, options = {})
-
# Check if there's already a pending request
-
existing_request = PersonalDataExportRequest.where(user: user, status: ['pending', 'processing']).first
-
if existing_request
-
raise StandardError, 'An export request is already pending or processing for this user'
-
end
-
-
# Create the request
-
export_request = PersonalDataExportRequest.create!(
-
user: user,
-
email: user.email,
-
requested_by: requested_by.id,
-
status: 'pending',
-
tenant: user.tenant
-
)
-
-
# Queue the export job
-
PersonalDataExportWorker.perform_async(export_request.id)
-
-
# Log the action
-
log_gdpr_action('export_requested', user, requested_by, {
-
request_id: export_request.id,
-
email: user.email
-
})
-
-
export_request
-
end
-
-
# Create a personal data erasure request
-
def create_erasure_request(user, requested_by, reason = nil)
-
# Check if there's already a pending request
-
existing_request = PersonalDataErasureRequest.where(user: user, status: ['pending_confirmation', 'processing']).first
-
if existing_request
-
raise StandardError, 'An erasure request is already pending or processing for this user'
-
end
-
-
# Gather metadata about what will be erased
-
metadata = gather_erasure_metadata(user)
-
-
# Create the request
-
erasure_request = PersonalDataErasureRequest.create!(
-
user: user,
-
email: user.email,
-
requested_by: requested_by.id,
-
status: 'pending_confirmation',
-
reason: reason,
-
metadata: metadata,
-
tenant: user.tenant
-
)
-
-
# Log the action
-
log_gdpr_action('erasure_requested', user, requested_by, {
-
request_id: erasure_request.id,
-
reason: reason,
-
metadata: metadata
-
})
-
-
erasure_request
-
end
-
-
# Confirm an erasure request
-
def confirm_erasure_request(erasure_request, confirmed_by)
-
erasure_request.update!(
-
status: 'processing',
-
confirmed_at: Time.current,
-
confirmed_by: confirmed_by.id
-
)
-
-
# Queue the erasure job
-
PersonalDataErasureWorker.perform_async(erasure_request.id)
-
-
# Log the action
-
log_gdpr_action('erasure_confirmed', erasure_request.user, confirmed_by, {
-
request_id: erasure_request.id,
-
reason: erasure_request.reason
-
})
-
-
erasure_request
-
end
-
-
# Generate comprehensive data portability information
-
def generate_portability_data(user)
-
{
-
user_profile: {
-
id: user.id,
-
email: user.email,
-
name: user.name,
-
role: user.role,
-
bio: user.bio,
-
website: user.website,
-
created_at: user.created_at,
-
updated_at: user.updated_at,
-
last_sign_in_at: user.last_sign_in_at,
-
sign_in_count: user.sign_in_count
-
},
-
posts: user.posts.map do |post|
-
{
-
id: post.id,
-
title: post.title,
-
slug: post.slug,
-
content: post.content.to_s,
-
excerpt: post.excerpt,
-
status: post.status,
-
published_at: post.published_at,
-
created_at: post.created_at,
-
updated_at: post.updated_at,
-
categories: post.categories.map(&:name),
-
tags: post.tags.map(&:name)
-
}
-
end,
-
pages: user.pages.map do |page|
-
{
-
id: page.id,
-
title: page.title,
-
slug: page.slug,
-
content: page.content.to_s,
-
status: page.status,
-
published_at: page.published_at,
-
created_at: page.created_at,
-
updated_at: page.updated_at
-
}
-
end,
-
comments: Comment.where(email: user.email).map do |comment|
-
{
-
id: comment.id,
-
content: comment.content,
-
author_name: comment.author_name,
-
author_email: comment.author_email,
-
status: comment.status,
-
post_title: comment.commentable&.title,
-
created_at: comment.created_at,
-
updated_at: comment.updated_at
-
}
-
end,
-
media: user.media.map do |medium|
-
{
-
id: medium.id,
-
filename: medium.filename,
-
content_type: medium.content_type,
-
file_size: medium.file_size,
-
alt_text: medium.alt_text,
-
caption: medium.caption,
-
created_at: medium.created_at
-
}
-
end,
-
subscribers: Subscriber.where(email: user.email).map do |subscriber|
-
{
-
id: subscriber.id,
-
email: subscriber.email,
-
status: subscriber.status,
-
subscribed_at: subscriber.created_at,
-
confirmed_at: subscriber.confirmed_at,
-
lists: subscriber.lists
-
}
-
end,
-
api_tokens: user.api_tokens.map do |token|
-
{
-
id: token.id,
-
name: token.name,
-
last_used_at: token.last_used_at,
-
created_at: token.created_at
-
}
-
end,
-
meta_fields: user.meta_fields.map do |field|
-
{
-
key: field.key,
-
value: field.value,
-
created_at: field.created_at,
-
updated_at: field.updated_at
-
}
-
end,
-
analytics_data: {
-
pageviews: Pageview.where(user_id: user.id).group(:path).count,
-
total_pageviews: Pageview.where(user_id: user.id).count,
-
last_pageview: Pageview.where(user_id: user.id).order(:created_at).last&.created_at
-
},
-
consent_records: get_user_consent_records(user),
-
gdpr_requests: {
-
export_requests: PersonalDataExportRequest.where(user: user).map do |req|
-
{
-
id: req.id,
-
status: req.status,
-
requested_at: req.created_at,
-
completed_at: req.completed_at
-
}
-
end,
-
erasure_requests: PersonalDataErasureRequest.where(user: user).map do |req|
-
{
-
id: req.id,
-
status: req.status,
-
reason: req.reason,
-
requested_at: req.created_at,
-
confirmed_at: req.confirmed_at,
-
completed_at: req.completed_at
-
}
-
end
-
},
-
metadata: {
-
total_posts: user.posts.count,
-
total_pages: user.pages.count,
-
total_comments: Comment.where(email: user.email).count,
-
total_media: user.media.count,
-
total_subscribers: Subscriber.where(email: user.email).count,
-
export_date: Time.current
-
}
-
}
-
end
-
-
# Get GDPR compliance status for a user
-
def get_user_gdpr_status(user)
-
{
-
user_id: user.id,
-
email: user.email,
-
compliance_status: {
-
data_processing_consent: get_consent_status(user, 'data_processing'),
-
marketing_consent: get_consent_status(user, 'marketing'),
-
analytics_consent: get_consent_status(user, 'analytics'),
-
cookie_consent: get_consent_status(user, 'cookies')
-
},
-
data_retention: {
-
account_created: user.created_at,
-
last_activity: user.last_sign_in_at || user.updated_at,
-
data_age_days: (Time.current - user.created_at).to_i / 1.day
-
},
-
pending_requests: {
-
export_requests: PersonalDataExportRequest.where(user: user, status: ['pending', 'processing']).count,
-
erasure_requests: PersonalDataErasureRequest.where(user: user, status: ['pending_confirmation', 'processing']).count
-
},
-
data_categories: {
-
profile_data: true,
-
content_data: user.posts.exists? || user.pages.exists?,
-
communication_data: Comment.where(email: user.email).exists?,
-
analytics_data: Pageview.where(user_id: user.id).exists?,
-
media_data: user.media.exists?,
-
subscription_data: Subscriber.where(email: user.email).exists?
-
},
-
legal_basis: {
-
consent: get_consent_status(user, 'data_processing') == 'granted',
-
withhold_consent: get_consent_status(user, 'data_processing') == 'withdrawn',
-
legitimate_interest: true # For analytics and security
-
}
-
}
-
end
-
-
# Record user consent
-
def record_user_consent(user, consent_type, consent_data)
-
consent_record = UserConsent.find_or_initialize_by(
-
user: user,
-
consent_type: consent_type
-
)
-
-
consent_record.assign_attributes(
-
granted: consent_data[:granted] || false,
-
consent_text: consent_data[:consent_text],
-
ip_address: consent_data[:ip_address],
-
user_agent: consent_data[:user_agent],
-
granted_at: consent_data[:granted] ? Time.current : nil,
-
withdrawn_at: consent_data[:granted] ? nil : Time.current
-
)
-
-
consent_record.save!
-
-
# Log the action
-
log_gdpr_action('consent_recorded', user, nil, {
-
consent_type: consent_type,
-
granted: consent_data[:granted],
-
consent_text: consent_data[:consent_text]
-
})
-
-
consent_record
-
end
-
-
# Withdraw user consent
-
def withdraw_user_consent(user, consent_type)
-
consent_record = UserConsent.find_by(user: user, consent_type: consent_type)
-
-
if consent_record
-
consent_record.update!(
-
granted: false,
-
withdrawn_at: Time.current
-
)
-
-
# Log the action
-
log_gdpr_action('consent_withdrawn', user, nil, {
-
consent_type: consent_type
-
})
-
-
consent_record
-
else
-
raise StandardError, 'No consent record found for this user and consent type'
-
end
-
end
-
-
# Get audit log for GDPR compliance
-
def get_audit_log(page = 1, per_page = 50)
-
offset = (page - 1) * per_page
-
-
# This would typically come from a dedicated audit log table
-
# For now, we'll simulate with existing data
-
audit_entries = []
-
-
# Export requests
-
PersonalDataExportRequest.includes(:user).recent.limit(per_page / 2).each do |req|
-
audit_entries << {
-
id: req.id,
-
action: 'data_export_requested',
-
user_email: req.email,
-
timestamp: req.created_at,
-
details: {
-
status: req.status,
-
completed_at: req.completed_at
-
}
-
}
-
end
-
-
# Erasure requests
-
PersonalDataErasureRequest.includes(:user).recent.limit(per_page / 2).each do |req|
-
audit_entries << {
-
id: req.id,
-
action: 'data_erasure_requested',
-
user_email: req.email,
-
timestamp: req.created_at,
-
details: {
-
status: req.status,
-
reason: req.reason,
-
confirmed_at: req.confirmed_at,
-
completed_at: req.completed_at
-
}
-
}
-
end
-
-
# Sort by timestamp and paginate
-
audit_entries.sort_by { |entry| entry[:timestamp] }.reverse
-
.slice(offset, per_page)
-
end
-
-
private
-
-
# Gather metadata about what will be erased
-
def gather_erasure_metadata(user)
-
{
-
user_posts_count: user.posts.count,
-
user_pages_count: user.pages.count,
-
user_comments_count: Comment.where(email: user.email).count,
-
user_media_count: user.media.count,
-
user_subscribers_count: Subscriber.where(email: user.email).count,
-
user_pageviews_count: Pageview.where(user_id: user.id).count,
-
user_api_tokens_count: user.api_tokens.count,
-
user_meta_fields_count: user.meta_fields.count,
-
account_age_days: (Time.current - user.created_at).to_i / 1.day,
-
last_activity: user.last_sign_in_at || user.updated_at
-
}
-
end
-
-
# Get consent status for a user
-
def get_consent_status(user, consent_type)
-
consent_record = UserConsent.find_by(user: user, consent_type: consent_type)
-
-
if consent_record
-
consent_record.granted ? 'granted' : 'withdrawn'
-
else
-
'not_recorded'
-
end
-
end
-
-
# Get user consent records
-
def get_user_consent_records(user)
-
UserConsent.where(user: user).map do |consent|
-
{
-
consent_type: consent.consent_type,
-
granted: consent.granted,
-
consent_text: consent.consent_text,
-
granted_at: consent.granted_at,
-
withdrawn_at: consent.withdrawn_at,
-
ip_address: consent.ip_address,
-
created_at: consent.created_at,
-
updated_at: consent.updated_at
-
}
-
end
-
end
-
-
# Log GDPR actions for audit trail
-
def log_gdpr_action(action, user, performed_by, details = {})
-
# In a real implementation, this would write to a dedicated audit log table
-
Rails.logger.info("GDPR Action: #{action} - User: #{user.email} - Performed by: #{performed_by&.email} - Details: #{details.to_json}")
-
-
# You could also store this in a dedicated audit log model:
-
# GdprAuditLog.create!(
-
# action: action,
-
# user: user,
-
# performed_by: performed_by,
-
# details: details,
-
# ip_address: RequestStore[:current_request]&.remote_ip,
-
# user_agent: RequestStore[:current_request]&.user_agent
-
# )
-
end
-
end
-
end
-
class GeolocationService
-
include Singleton
-
-
PROVIDERS = {
-
'maxmind' => 'MaxMind GeoLite2 Database',
-
'ipapi' => 'IP-API.com (Free)',
-
'ipinfo' => 'IPInfo.io (Free tier)',
-
'ipgeolocation' => 'IP Geolocation API',
-
'abstract' => 'Abstract API'
-
}.freeze
-
-
def initialize
-
@provider = SiteSetting.get('geolocation_provider', 'maxmind')
-
@maxmind_db_path = Rails.root.join('db', 'maxmind', 'GeoLite2-Country.mmdb')
-
@maxmind_city_db_path = Rails.root.join('db', 'maxmind', 'GeoLite2-City.mmdb')
-
end
-
-
def lookup_ip(ip_address)
-
return nil if ip_address.blank? || private_ip?(ip_address)
-
-
# Check if geolocation is enabled (disabled by default for GDPR compliance)
-
return nil unless SiteSetting.get('geolocation_enabled', false)
-
-
# Check if user has consented (if consent is required)
-
if SiteSetting.get('geolocation_require_consent', true)
-
# This would need to be implemented based on your consent system
-
# For now, we'll assume consent is given if geolocation is enabled
-
end
-
-
# Anonymize IP if required
-
processed_ip = ip_address
-
if SiteSetting.get('geolocation_anonymize_ip', true) && !SiteSetting.get('geolocation_full_power_mode', false)
-
processed_ip = anonymize_ip(ip_address)
-
end
-
-
case @provider
-
when 'maxmind'
-
maxmind_lookup(processed_ip)
-
when 'ipapi'
-
ipapi_lookup(processed_ip)
-
when 'ipinfo'
-
ipinfo_lookup(processed_ip)
-
when 'ipgeolocation'
-
ipgeolocation_lookup(processed_ip)
-
when 'abstract'
-
abstract_lookup(processed_ip)
-
else
-
maxmind_lookup(processed_ip) # fallback to MaxMind
-
end
-
rescue => e
-
Rails.logger.error "Geolocation lookup failed for #{ip_address}: #{e.message}"
-
nil
-
end
-
-
def maxmind_lookup(ip_address)
-
return nil unless maxmind_available?
-
-
begin
-
# Try City database first for more detailed info
-
if File.exist?(@maxmind_city_db_path)
-
db = MaxMindDB.new(@maxmind_city_db_path.to_s)
-
result = db.lookup(ip_address)
-
-
if result.found?
-
city_record = result.record
-
return {
-
country_code: city_record.country.iso_code,
-
country_name: city_record.country.names['en'],
-
city: city_record.city.names['en'],
-
region: city_record.subdivisions&.first&.names&.dig('en'),
-
latitude: city_record.location.latitude,
-
longitude: city_record.location.longitude,
-
timezone: city_record.location.time_zone,
-
accuracy_radius: city_record.location.accuracy_radius,
-
provider: 'maxmind_city'
-
}
-
end
-
end
-
-
# Fallback to Country database
-
if File.exist?(@maxmind_db_path)
-
db = MaxMindDB.new(@maxmind_db_path.to_s)
-
result = db.lookup(ip_address)
-
-
if result.found?
-
country_record = result.record
-
return {
-
country_code: country_record.country.iso_code,
-
country_name: country_record.country.names['en'],
-
provider: 'maxmind_country'
-
}
-
end
-
end
-
rescue => e
-
Rails.logger.error "MaxMind lookup failed: #{e.message}"
-
end
-
-
nil
-
end
-
-
def ipapi_lookup(ip_address)
-
return nil unless SiteSetting.get('geolocation_ipapi_enabled', false)
-
-
begin
-
response = HTTP.timeout(5).get("http://ip-api.com/json/#{ip_address}")
-
data = JSON.parse(response.body.to_s)
-
-
if data['status'] == 'success'
-
{
-
country_code: data['countryCode'],
-
country_name: data['country'],
-
city: data['city'],
-
region: data['regionName'],
-
latitude: data['lat'],
-
longitude: data['lon'],
-
timezone: data['timezone'],
-
isp: data['isp'],
-
org: data['org'],
-
provider: 'ipapi'
-
}
-
end
-
rescue => e
-
Rails.logger.error "IP-API lookup failed: #{e.message}"
-
end
-
-
nil
-
end
-
-
def ipinfo_lookup(ip_address)
-
return nil unless SiteSetting.get('geolocation_ipinfo_enabled', false)
-
-
api_key = SiteSetting.get('geolocation_ipinfo_api_key', '')
-
return nil if api_key.blank?
-
-
begin
-
url = "https://ipinfo.io/#{ip_address}/json"
-
url += "?token=#{api_key}" if api_key.present?
-
-
response = HTTP.timeout(5).get(url)
-
data = JSON.parse(response.body.to_s)
-
-
unless data['error']
-
lat_lng = data['loc']&.split(',')
-
{
-
country_code: data['country'],
-
country_name: country_name_from_code(data['country']),
-
city: data['city'],
-
region: data['region'],
-
latitude: lat_lng&.first&.to_f,
-
longitude: lat_lng&.last&.to_f,
-
timezone: data['timezone'],
-
isp: data['org'],
-
provider: 'ipinfo'
-
}
-
end
-
rescue => e
-
Rails.logger.error "IPInfo lookup failed: #{e.message}"
-
end
-
-
nil
-
end
-
-
def ipgeolocation_lookup(ip_address)
-
return nil unless SiteSetting.get('geolocation_ipgeolocation_enabled', false)
-
-
api_key = SiteSetting.get('geolocation_ipgeolocation_api_key', '')
-
return nil if api_key.blank?
-
-
begin
-
response = HTTP.timeout(5).get("https://api.ipgeolocation.io/ipgeo", params: {
-
apiKey: api_key,
-
ip: ip_address
-
})
-
data = JSON.parse(response.body.to_s)
-
-
{
-
country_code: data['country_code2'],
-
country_name: data['country_name'],
-
city: data['city'],
-
region: data['state_prov'],
-
latitude: data['latitude']&.to_f,
-
longitude: data['longitude']&.to_f,
-
timezone: data['time_zone']&.dig('name'),
-
isp: data['isp'],
-
provider: 'ipgeolocation'
-
}
-
rescue => e
-
Rails.logger.error "IP Geolocation lookup failed: #{e.message}"
-
end
-
-
nil
-
end
-
-
def abstract_lookup(ip_address)
-
return nil unless SiteSetting.get('geolocation_abstract_enabled', false)
-
-
api_key = SiteSetting.get('geolocation_abstract_api_key', '')
-
return nil if api_key.blank?
-
-
begin
-
response = HTTP.timeout(5).get("https://ipgeolocation.abstractapi.com/v1/", params: {
-
api_key: api_key,
-
ip_address: ip_address
-
})
-
data = JSON.parse(response.body.to_s)
-
-
{
-
country_code: data['country_code'],
-
country_name: data['country'],
-
city: data['city'],
-
region: data['region'],
-
latitude: data['latitude']&.to_f,
-
longitude: data['longitude']&.to_f,
-
timezone: data['timezone']&.dig('name'),
-
provider: 'abstract'
-
}
-
rescue => e
-
Rails.logger.error "Abstract API lookup failed: #{e.message}"
-
end
-
-
nil
-
end
-
-
def maxmind_available?
-
File.exist?(@maxmind_db_path) || File.exist?(@maxmind_city_db_path)
-
end
-
-
def maxmind_database_info
-
info = {}
-
-
if File.exist?(@maxmind_db_path)
-
stat = File.stat(@maxmind_db_path)
-
info[:country_db] = {
-
path: @maxmind_db_path,
-
size: stat.size,
-
modified: stat.mtime,
-
available: true
-
}
-
end
-
-
if File.exist?(@maxmind_city_db_path)
-
stat = File.stat(@maxmind_city_db_path)
-
info[:city_db] = {
-
path: @maxmind_city_db_path,
-
size: stat.size,
-
modified: stat.mtime,
-
available: true
-
}
-
end
-
-
info
-
end
-
-
def test_lookup(ip_address = '8.8.8.8')
-
result = lookup_ip(ip_address)
-
-
if result
-
{
-
success: true,
-
data: result,
-
provider: result[:provider],
-
message: "Successfully resolved #{ip_address}"
-
}
-
else
-
{
-
success: false,
-
message: "Failed to resolve #{ip_address} with provider #{@provider}"
-
}
-
end
-
end
-
-
private
-
-
def private_ip?(ip_address)
-
ip = IPAddr.new(ip_address)
-
ip.private? || ip.loopback? || ip.link_local?
-
rescue
-
true # treat invalid IPs as private
-
end
-
-
def anonymize_ip(ip_address)
-
# Anonymize IP by zeroing out the last octet for IPv4 or last 80 bits for IPv6
-
begin
-
ip = IPAddr.new(ip_address)
-
if ip.ipv4?
-
# Zero out the last octet for IPv4
-
parts = ip_address.split('.')
-
parts[3] = '0'
-
parts.join('.')
-
elsif ip.ipv6?
-
# Zero out the last 80 bits for IPv6 (last 10 hex characters)
-
ip_str = ip.to_s
-
if ip_str.include?('::')
-
# Handle compressed IPv6 addresses
-
ip_str.gsub(/::[^:]*$/, '::')
-
else
-
# Handle full IPv6 addresses
-
parts = ip_str.split(':')
-
parts[-1] = '0000'
-
parts.join(':')
-
end
-
else
-
ip_address
-
end
-
rescue
-
ip_address # return original if anonymization fails
-
end
-
end
-
-
def filter_geolocation_data(data)
-
# Filter data based on GDPR settings
-
filtered_data = {}
-
-
# Always include country if enabled
-
if SiteSetting.get('geolocation_collect_country', true) && data[:country_code]
-
filtered_data[:country_code] = data[:country_code]
-
filtered_data[:country_name] = data[:country_name]
-
end
-
-
# Include region if enabled
-
if SiteSetting.get('geolocation_collect_region', false) && data[:region]
-
filtered_data[:region] = data[:region]
-
end
-
-
# Include city if enabled
-
if SiteSetting.get('geolocation_collect_city', false) && data[:city]
-
filtered_data[:city] = data[:city]
-
end
-
-
# Include coordinates if enabled
-
if SiteSetting.get('geolocation_collect_coordinates', false) && data[:latitude] && data[:longitude]
-
filtered_data[:latitude] = data[:latitude]
-
filtered_data[:longitude] = data[:longitude]
-
end
-
-
# Include timezone if available
-
filtered_data[:timezone] = data[:timezone] if data[:timezone]
-
-
filtered_data
-
end
-
-
def country_name_from_code(code)
-
# Simple country code to name mapping
-
country_names = {
-
'US' => 'United States',
-
'GB' => 'United Kingdom',
-
'CA' => 'Canada',
-
'DE' => 'Germany',
-
'FR' => 'France',
-
'IT' => 'Italy',
-
'ES' => 'Spain',
-
'NL' => 'Netherlands',
-
'AU' => 'Australia',
-
'JP' => 'Japan',
-
'CN' => 'China',
-
'IN' => 'India',
-
'BR' => 'Brazil',
-
'RU' => 'Russia',
-
'MX' => 'Mexico'
-
}
-
-
country_names[code] || code
-
end
-
end
-
class ImageOptimizationService
-
include ActiveSupport::Configurable
-
-
# Configuration defaults
-
config_accessor :quality, :max_width, :max_height, :strip_metadata, :enable_webp, :enable_avif, :compression_level
-
-
# Default values
-
self.quality = 85
-
self.max_width = 2000
-
self.max_height = 2000
-
self.strip_metadata = true
-
self.enable_webp = true
-
self.enable_avif = true
-
self.compression_level = 6
-
-
# Supported image formats
-
SUPPORTED_FORMATS = {
-
# Traditional formats
-
'jpeg' => { mime: 'image/jpeg', extension: 'jpg', modern: false },
-
'png' => { mime: 'image/png', extension: 'png', modern: false },
-
'gif' => { mime: 'image/gif', extension: 'gif', modern: false },
-
'bmp' => { mime: 'image/bmp', extension: 'bmp', modern: false },
-
'tiff' => { mime: 'image/tiff', extension: 'tiff', modern: false },
-
-
# Modern formats
-
'webp' => { mime: 'image/webp', extension: 'webp', modern: true, compression: 'excellent' },
-
'avif' => { mime: 'image/avif', extension: 'avif', modern: true, compression: 'excellent' },
-
'heic' => { mime: 'image/heic', extension: 'heic', modern: true, compression: 'excellent' },
-
'heif' => { mime: 'image/heif', extension: 'heif', modern: true, compression: 'excellent' },
-
'jxl' => { mime: 'image/jxl', extension: 'jxl', modern: true, compression: 'excellent' },
-
'jp2' => { mime: 'image/jp2', extension: 'jp2', modern: true, compression: 'good' },
-
'j2k' => { mime: 'image/j2k', extension: 'j2k', modern: true, compression: 'good' }
-
}.freeze
-
-
# Compression level configurations (inspired by Smush)
-
COMPRESSION_LEVELS = {
-
'lossless' => {
-
name: 'Lossless',
-
description: 'Maximum quality, minimal compression',
-
quality: 95,
-
compression_level: 1,
-
lossy: false,
-
expected_savings: '5-15%',
-
recommended_for: 'Professional photography, high-quality images'
-
},
-
'lossy' => {
-
name: 'Lossy',
-
description: 'Balanced quality and compression',
-
quality: 85,
-
compression_level: 6,
-
lossy: true,
-
expected_savings: '25-40%',
-
recommended_for: 'General web images, blog posts'
-
},
-
'ultra' => {
-
name: 'Ultra',
-
description: 'Maximum compression, slight quality loss',
-
quality: 75,
-
compression_level: 9,
-
lossy: true,
-
expected_savings: '40-60%',
-
recommended_for: 'High-traffic sites, mobile optimization'
-
},
-
'custom' => {
-
name: 'Custom',
-
description: 'User-defined settings',
-
quality: 85, # Default fallback
-
compression_level: 6, # Default fallback
-
lossy: true, # Default fallback
-
expected_savings: 'Variable',
-
recommended_for: 'Advanced users'
-
}
-
}.freeze
-
-
def initialize(medium, optimization_type: 'upload', request_context: {})
-
@medium = medium
-
@upload = medium&.upload
-
@storage_config = StorageConfigurationService.new
-
@optimization_type = optimization_type
-
@request_context = request_context
-
@start_time = Time.current
-
@log_entry = nil
-
load_settings
-
end
-
-
# Main optimization method
-
def optimize!
-
return false unless should_optimize?
-
-
Rails.logger.info "Starting image optimization for medium #{@medium.id}"
-
-
# Create log entry
-
create_log_entry
-
-
begin
-
# Get original file
-
original_file = @upload.file.download
-
original_size = original_file.size
-
-
# Process the image
-
processed_file = process_image(original_file)
-
-
if processed_file && processed_file.size < original_size
-
# Replace the original file with optimized version
-
replace_file(processed_file)
-
-
# Generate variants (WebP, AVIF, HEIC, JXL, etc.)
-
variants_generated = []
-
responsive_variants_generated = []
-
-
if variants_enabled?
-
variants_generated = generate_all_variants(original_file)
-
responsive_variants_generated = generate_responsive_variants!(original_file)
-
end
-
-
# Update log entry with success
-
update_log_entry(
-
status: 'success',
-
original_size: original_size,
-
optimized_size: processed_file.size,
-
variants_generated: variants_generated,
-
responsive_variants_generated: responsive_variants_generated
-
)
-
-
Rails.logger.info "Image optimization completed for medium #{@medium.id}. Size reduced from #{original_size} to #{processed_file.size} bytes"
-
true
-
else
-
# Update log entry with skipped status
-
update_log_entry(
-
status: 'skipped',
-
original_size: original_size,
-
optimized_size: original_size,
-
error_message: 'No size reduction achieved'
-
)
-
-
Rails.logger.info "Image optimization skipped for medium #{@medium.id} - no size reduction achieved"
-
false
-
end
-
rescue => e
-
# Update log entry with error
-
update_log_entry(
-
status: 'failed',
-
original_size: original_file&.size || 0,
-
optimized_size: original_file&.size || 0,
-
error_message: e.message
-
)
-
-
Rails.logger.error "Image optimization failed for medium #{@medium.id}: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
false
-
end
-
end
-
-
# Generate all modern format variants
-
def generate_all_variants(original_file)
-
variants_generated = []
-
-
# Generate WebP variant
-
if enable_webp
-
webp_file = generate_format_variant(original_file, 'webp')
-
if webp_file
-
store_variant(webp_file, 'webp')
-
variants_generated << 'webp'
-
end
-
end
-
-
# Generate AVIF variant
-
if enable_avif
-
avif_file = generate_format_variant(original_file, 'avif')
-
if avif_file
-
store_variant(avif_file, 'avif')
-
variants_generated << 'avif'
-
end
-
end
-
-
# Generate HEIC variant (if enabled)
-
if SiteSetting.get('enable_heic_variants', false)
-
heic_file = generate_format_variant(original_file, 'heic')
-
if heic_file
-
store_variant(heic_file, 'heic')
-
variants_generated << 'heic'
-
end
-
end
-
-
# Generate JXL variant (if enabled)
-
if SiteSetting.get('enable_jxl_variants', false)
-
jxl_file = generate_format_variant(original_file, 'jxl')
-
if jxl_file
-
store_variant(jxl_file, 'jxl')
-
variants_generated << 'jxl'
-
end
-
end
-
-
variants_generated
-
end
-
-
# Generate optimized variants (legacy method for compatibility)
-
def generate_variants!
-
return false unless variants_enabled?
-
-
Rails.logger.info "Generating image variants for medium #{@medium.id}"
-
-
begin
-
original_file = @upload.file.download
-
variants_generated = generate_all_variants(original_file)
-
-
# Generate responsive breakpoint variants
-
generate_responsive_variants!(original_file)
-
-
Rails.logger.info "Image variants generated for medium #{@medium.id}: #{variants_generated.join(', ')}"
-
true
-
rescue => e
-
Rails.logger.error "Variant generation failed for medium #{@medium.id}: #{e.message}"
-
false
-
end
-
end
-
-
# Generate responsive variants for different breakpoints
-
def generate_responsive_variants!(original_file)
-
return false unless SiteSetting.get('enable_responsive_variants', true)
-
-
breakpoints = SiteSetting.get('responsive_breakpoints', '320,640,768,1024,1200,1920').split(',').map(&:to_i)
-
responsive_variants_generated = []
-
-
breakpoints.each do |width|
-
# Generate WebP responsive variants
-
if enable_webp
-
webp_responsive = generate_responsive_variant(original_file, width, 'webp')
-
if webp_responsive
-
store_responsive_variant(webp_responsive, 'webp', width)
-
responsive_variants_generated << "webp_#{width}w"
-
end
-
end
-
-
# Generate AVIF responsive variants
-
if enable_avif
-
avif_responsive = generate_responsive_variant(original_file, width, 'avif')
-
if avif_responsive
-
store_responsive_variant(avif_responsive, 'avif', width)
-
responsive_variants_generated << "avif_#{width}w"
-
end
-
end
-
-
# Generate original format responsive variants
-
original_responsive = generate_responsive_variant(original_file, width, 'original')
-
if original_responsive
-
store_responsive_variant(original_responsive, 'original', width)
-
responsive_variants_generated << "original_#{width}w"
-
end
-
end
-
-
responsive_variants_generated
-
end
-
-
# Get compression level information
-
def compression_level_info
-
@compression_config || COMPRESSION_LEVELS['lossy']
-
end
-
-
def compression_level_name
-
@compression_level_name || 'lossy'
-
end
-
-
def expected_savings
-
compression_level_info[:expected_savings]
-
end
-
-
def recommended_for
-
compression_level_info[:recommended_for]
-
end
-
-
# Class method to get all available compression levels
-
def self.available_compression_levels
-
COMPRESSION_LEVELS
-
end
-
-
# Class method to get all supported formats
-
def self.supported_formats
-
SUPPORTED_FORMATS
-
end
-
-
# Class method to get modern formats only
-
def self.modern_formats
-
SUPPORTED_FORMATS.select { |_, config| config[:modern] }
-
end
-
-
# Class method to get traditional formats only
-
def self.traditional_formats
-
SUPPORTED_FORMATS.select { |_, config| !config[:modern] }
-
end
-
-
# Class method to check if format is supported
-
def self.supports_format?(format)
-
SUPPORTED_FORMATS.key?(format.to_s.downcase)
-
end
-
-
# Class method to check if format is modern
-
def self.modern_format?(format)
-
SUPPORTED_FORMATS[format.to_s.downcase]&.dig(:modern) == true
-
end
-
-
# Instance methods for compression level info
-
def compression_level_info
-
@compression_config || COMPRESSION_LEVELS['lossy']
-
end
-
-
def compression_level_name
-
@compression_level_name || 'lossy'
-
end
-
-
def expected_savings
-
compression_level_info[:expected_savings]
-
end
-
-
def recommended_for
-
compression_level_info[:recommended_for]
-
end
-
-
private
-
-
def should_optimize?
-
return false unless @medium.image?
-
return false unless @upload.file.attached?
-
return false unless @storage_config.auto_optimize_enabled?
-
-
# Check if optimization is enabled in media settings
-
SiteSetting.get('auto_optimize_images', false)
-
end
-
-
def load_settings
-
# Get compression level setting
-
compression_level_name = SiteSetting.get('image_compression_level', 'lossy')
-
compression_config = COMPRESSION_LEVELS[compression_level_name] || COMPRESSION_LEVELS['lossy']
-
-
# Apply compression level settings
-
if compression_config[:quality]
-
self.quality = compression_config[:quality]
-
else
-
# Use custom settings for custom level
-
self.quality = SiteSetting.get('image_quality', 85).to_i
-
end
-
-
if compression_config[:compression_level]
-
self.compression_level = compression_config[:compression_level]
-
else
-
# Use custom settings for custom level
-
self.compression_level = SiteSetting.get('image_compression_level_value', 6).to_i
-
end
-
-
# Other settings
-
self.max_width = SiteSetting.get('image_max_width', 2000).to_i
-
self.max_height = SiteSetting.get('image_max_height', 2000).to_i
-
self.strip_metadata = SiteSetting.get('strip_image_metadata', true)
-
self.enable_webp = SiteSetting.get('enable_webp_variants', true)
-
self.enable_avif = SiteSetting.get('enable_avif_variants', true)
-
-
# Store compression level info
-
@compression_level_name = compression_level_name
-
@compression_config = compression_config
-
end
-
-
def process_image(file_data)
-
require 'image_processing/vips'
-
-
# Create a temporary file for processing
-
temp_file = Tempfile.new(['original_input', '.jpg'])
-
temp_file.binmode
-
temp_file.write(file_data)
-
temp_file.rewind
-
-
processed = ImageProcessing::Vips
-
.source(temp_file.path)
-
.resize_to_limit(max_width, max_height)
-
.saver(
-
quality: quality,
-
strip: strip_metadata,
-
optimize: true,
-
compression_level: compression_level # Apply compression level
-
)
-
-
result = processed.call
-
File.read(result.path)
-
rescue => e
-
Rails.logger.warn "Image processing failed: #{e.message}"
-
nil
-
ensure
-
temp_file&.close
-
temp_file&.unlink
-
File.unlink(result.path) if result && File.exist?(result.path)
-
end
-
-
def generate_format_variant(image_data, format)
-
return nil unless image_data
-
-
begin
-
require 'image_processing/vips'
-
-
temp_file = Tempfile.new(['variant_input', '.jpg'])
-
temp_file.binmode
-
temp_file.write(image_data)
-
temp_file.rewind
-
-
processed = ImageProcessing::Vips
-
.source(temp_file.path)
-
.convert(format)
-
.saver(
-
quality: quality,
-
strip: strip_metadata,
-
lossless: false,
-
compression_level: compression_level
-
)
-
-
result = processed.call
-
File.read(result.path)
-
rescue => e
-
Rails.logger.warn "#{format.upcase} variant generation failed: #{e.message}"
-
nil
-
ensure
-
temp_file&.close
-
temp_file&.unlink
-
File.unlink(result.path) if result && File.exist?(result.path)
-
end
-
end
-
-
def replace_file(new_file_data)
-
# Delete old blob
-
@upload.file.purge
-
-
# Attach new blob
-
@upload.file.attach(
-
io: StringIO.new(new_file_data),
-
filename: @upload.file.filename.to_s,
-
content_type: @upload.file.content_type
-
)
-
-
# Update file size
-
@upload.update!(file_size: new_file_data.size)
-
end
-
-
def store_variant(variant_data, format)
-
return unless variant_data
-
-
# Create variant blob
-
variant_blob = ActiveStorage::Blob.create_and_upload!(
-
io: StringIO.new(variant_data),
-
filename: "#{@upload.file.filename.base}.#{format}",
-
content_type: "image/#{format}"
-
)
-
-
# Store variant metadata in upload
-
variants = @upload.variants || {}
-
variants[format] = {
-
blob_id: variant_blob.id,
-
size: variant_data.size,
-
created_at: Time.current
-
}
-
@upload.update!(variants: variants)
-
end
-
-
def generate_responsive_variant(image_data, width, format)
-
return nil unless image_data
-
-
begin
-
require 'image_processing/vips'
-
-
temp_file = Tempfile.new(['responsive_input', '.jpg'])
-
temp_file.binmode
-
temp_file.write(image_data)
-
temp_file.rewind
-
-
processed = ImageProcessing::Vips
-
.source(temp_file.path)
-
.resize_to_limit(width, width * 2) # Allow 2:1 aspect ratio
-
.saver(
-
quality: quality,
-
strip: strip_metadata,
-
optimize: true
-
)
-
-
# Convert to specific format if needed
-
if format == 'webp'
-
processed = processed.convert('webp').saver(lossless: false)
-
elsif format == 'avif'
-
processed = processed.convert('avif').saver(lossless: false)
-
end
-
-
result = processed.call
-
File.read(result.path)
-
rescue => e
-
Rails.logger.warn "Responsive variant generation failed (#{format}, #{width}px): #{e.message}"
-
nil
-
ensure
-
temp_file&.close
-
temp_file&.unlink
-
File.unlink(result.path) if result && File.exist?(result.path)
-
end
-
end
-
-
def store_responsive_variant(variant_data, format, width)
-
return unless variant_data
-
-
# Create responsive variant blob
-
extension = format == 'original' ? @upload.file.filename.extension : format
-
variant_blob = ActiveStorage::Blob.create_and_upload!(
-
io: StringIO.new(variant_data),
-
filename: "#{@upload.file.filename.base}_#{width}w.#{extension}",
-
content_type: format == 'original' ? @upload.file.content_type : "image/#{format}"
-
)
-
-
# Store responsive variant metadata in upload
-
variants = @upload.variants || {}
-
responsive_key = "#{format}_#{width}w"
-
variants[responsive_key] = {
-
blob_id: variant_blob.id,
-
size: variant_data.size,
-
width: width,
-
format: format,
-
created_at: Time.current
-
}
-
@upload.update!(variants: variants)
-
end
-
-
# Logging methods
-
def create_log_entry
-
@log_entry = ImageOptimizationLog.create!(
-
medium: @medium,
-
upload: @upload,
-
user: @medium.user,
-
tenant: @medium.tenant,
-
filename: @upload.filename,
-
content_type: @upload.content_type,
-
compression_level: compression_level_name,
-
quality: quality,
-
strip_metadata: strip_metadata,
-
enable_webp: enable_webp,
-
enable_avif: enable_avif,
-
optimization_type: @optimization_type,
-
status: 'processing',
-
processing_time: 0,
-
storage_provider: @upload.storage_provider&.name,
-
cdn_enabled: @storage_config.cdn_enabled?,
-
user_agent: @request_context[:user_agent],
-
ip_address: @request_context[:ip_address]
-
)
-
rescue => e
-
Rails.logger.error "Failed to create log entry: #{e.message}"
-
@log_entry = nil
-
end
-
-
def update_log_entry(attributes)
-
return unless @log_entry
-
-
processing_time = Time.current - @start_time
-
-
@log_entry.update!(
-
attributes.merge(
-
processing_time: processing_time
-
)
-
)
-
rescue => e
-
Rails.logger.error "Failed to update log entry: #{e.message}"
-
end
-
-
def log_warning(message)
-
return unless @log_entry
-
-
warnings = @log_entry.warnings || []
-
warnings << message
-
@log_entry.update!(warnings: warnings)
-
rescue => e
-
Rails.logger.error "Failed to log warning: #{e.message}"
-
end
-
end
-
1
class LiquidTemplateRenderer
-
1
def initialize(theme_name, template_type, template_data = {})
-
@theme_name = theme_name
-
@template_type = template_type
-
@template_data = template_data
-
@theme_path = Rails.root.join('app', 'themes', theme_name)
-
end
-
-
1
def render
-
# Load the template structure
-
template_structure = load_template_structure
-
-
# Render the layout
-
layout_content = render_layout
-
-
# Render sections in order
-
sections_html = render_sections(template_structure)
-
-
# Combine layout with sections
-
layout_content.gsub('{{ content_for_layout }}', sections_html)
-
end
-
-
1
def render_section(section_id, section_data)
-
section_type = section_data['type']
-
section_settings = section_data['settings'] || {}
-
-
# Load section file
-
section_file = @theme_path.join('sections', "#{section_type}.liquid")
-
else: 0
then: 0
return '' unless File.exist?(section_file)
-
-
section_content = File.read(section_file)
-
-
# Create liquid template with section data
-
template = Liquid::Template.parse(section_content)
-
-
# Prepare context with section settings
-
context = {
-
'section' => {
-
'settings' => section_settings,
-
'id' => section_id,
-
'type' => section_type
-
}
-
}
-
-
# Add global theme settings
-
context['settings'] = load_theme_settings
-
-
# Render the section
-
template.render(context)
-
rescue => e
-
Rails.logger.error "Error rendering section #{section_id}: #{e.message}"
-
"<div class='error'>Error rendering section: #{section_type}</div>"
-
end
-
-
1
private
-
-
1
def load_template_structure
-
template_file = @theme_path.join('templates', "#{@template_type}.json")
-
-
then: 0
if File.exist?(template_file)
-
JSON.parse(File.read(template_file))
-
else: 0
else
-
@template_data
-
end
-
end
-
-
1
def render_layout
-
layout_file = @theme_path.join('layout', 'theme.liquid')
-
-
then: 0
if File.exist?(layout_file)
-
layout_content = File.read(layout_file)
-
-
# Create liquid template
-
template = Liquid::Template.parse(layout_content)
-
-
# Prepare context
-
context = {
-
'template' => @template_type,
-
'settings' => load_theme_settings,
-
'page' => load_page_data
-
}
-
-
# Render the layout
-
template.render(context)
-
else
-
else: 0
# Default layout if theme.liquid doesn't exist
-
default_layout
-
end
-
rescue => e
-
Rails.logger.error "Error rendering layout: #{e.message}"
-
default_layout
-
end
-
-
1
def render_sections(template_structure)
-
sections_html = ''
-
-
then: 0
else: 0
if template_structure['order'] && template_structure['sections']
-
template_structure['order'].each do |section_id|
-
section_data = template_structure['sections'][section_id]
-
then: 0
else: 0
if section_data
-
sections_html += render_section(section_id, section_data)
-
end
-
end
-
end
-
-
sections_html
-
end
-
-
1
def load_theme_settings
-
settings_file = @theme_path.join('config', 'settings_schema.json')
-
-
then: 0
if File.exist?(settings_file)
-
settings_schema = JSON.parse(File.read(settings_file))
-
-
# Convert schema to settings with defaults
-
settings = {}
-
settings_schema.each do |group|
-
group['settings'].each do |setting|
-
settings[setting['id']] = setting['default']
-
end
-
end
-
-
settings
-
else: 0
else
-
{}
-
end
-
end
-
-
1
def load_page_data
-
# Load page-specific data based on template type
-
case @template_type
-
when: 0
when 'index'
-
{
-
'title' => 'Homepage',
-
'description' => 'Welcome to our site'
-
}
-
when: 0
when 'blog'
-
{
-
'title' => 'Blog',
-
'description' => 'Latest posts'
-
}
-
when: 0
when 'page'
-
{
-
'title' => 'Page',
-
'description' => 'Page content'
-
}
-
when: 0
when 'post'
-
{
-
'title' => 'Blog Post',
-
'description' => 'Post content'
-
}
-
else: 0
else
-
{
-
'title' => @template_type.humanize,
-
'description' => ''
-
}
-
end
-
end
-
-
1
def default_layout
-
<<~HTML
-
<!DOCTYPE html>
-
<html>
-
<head>
-
<title>{{ page.title }}</title>
-
<meta name="description" content="{{ page.description }}">
-
<link rel="stylesheet" href="/assets/theme.css">
-
</head>
-
<body>
-
{{ content_for_layout }}
-
<script src="/assets/theme.js"></script>
-
</body>
-
</html>
-
HTML
-
end
-
end
-
class LiquidTemplateVersionRenderer
-
def initialize(theme_version, template_type)
-
@theme_version = theme_version
-
@template_type = template_type
-
@theme_name = theme_version.theme_name
-
end
-
-
def render
-
# Get template data from the theme version
-
template_data = @theme_version.template_data(@template_type)
-
-
# Render the layout with sections
-
layout_content = render_layout
-
-
# Render sections in order
-
sections_html = render_sections(template_data)
-
-
# Combine layout with sections
-
layout_content.gsub('{{ content_for_layout }}', sections_html)
-
end
-
-
def render_section(section_id, section_data)
-
section_type = section_data['type']
-
section_settings = section_data['settings'] || {}
-
-
# Get section content from theme version
-
section_content = @theme_version.section_content(section_type)
-
return '' if section_content.blank?
-
-
# Create liquid template with section data
-
template = Liquid::Template.parse(section_content)
-
-
# Prepare context with section settings
-
context = {
-
'section' => {
-
'settings' => section_settings,
-
'id' => section_id,
-
'type' => section_type
-
}
-
}
-
-
# Add global theme settings
-
context['settings'] = load_theme_settings
-
-
# Add sample data for preview
-
# No sample data needed - use real data
-
-
# Render the section
-
template.render(context)
-
rescue => e
-
Rails.logger.error "Error rendering section #{section_id}: #{e.message}"
-
"<div class='error'>Error rendering section: #{section_type}</div>"
-
end
-
-
private
-
-
def render_layout
-
layout_content = @theme_version.layout_content
-
-
if layout_content.present?
-
# Create liquid template
-
template = Liquid::Template.parse(layout_content)
-
-
# Prepare context
-
context = {
-
'template' => @template_type,
-
'settings' => load_theme_settings,
-
'page' => load_page_data
-
}
-
-
# Add sample data
-
# No sample data needed - use real data
-
-
# Render the layout
-
template.render(context)
-
else
-
# Default layout if theme.liquid doesn't exist
-
default_layout
-
end
-
rescue => e
-
Rails.logger.error "Error rendering layout: #{e.message}"
-
default_layout
-
end
-
-
def render_sections(template_data)
-
sections_html = ''
-
-
if template_data['order'] && template_data['sections']
-
template_data['order'].each do |section_id|
-
section_data = template_data['sections'][section_id]
-
if section_data
-
sections_html += render_section(section_id, section_data)
-
end
-
end
-
end
-
-
sections_html
-
end
-
-
def load_theme_settings
-
# Get theme settings from the theme version
-
settings_file_content = @theme_version.file_content('config/settings_schema.json')
-
-
if settings_file_content.present?
-
settings_schema = JSON.parse(settings_file_content)
-
-
# Convert schema to settings with defaults
-
settings = {}
-
settings_schema.each do |group|
-
group['settings'].each do |setting|
-
settings[setting['id']] = setting['default']
-
end
-
end
-
-
settings
-
else
-
{}
-
end
-
rescue JSON::ParserError
-
{}
-
end
-
-
def load_page_data
-
# Load page-specific data based on template type
-
case @template_type
-
when 'index'
-
{
-
'title' => 'Homepage',
-
'description' => 'Welcome to our site',
-
'posts' => []
-
}
-
when 'blog'
-
{
-
'title' => 'Blog',
-
'description' => 'Latest posts',
-
'posts' => []
-
}
-
when 'page'
-
{
-
'title' => 'Sample Page',
-
'description' => 'This is a sample page',
-
'content' => '<p>This is sample page content for preview.</p>'
-
}
-
when 'post'
-
{
-
'title' => 'Sample Blog Post',
-
'description' => 'This is a sample blog post',
-
'content' => '<p>This is sample blog post content for preview.</p>',
-
'author' => 'Sample Author',
-
'date' => Time.current.strftime('%B %d, %Y')
-
}
-
else
-
{
-
'title' => @template_type.humanize,
-
'description' => ''
-
}
-
end
-
end
-
-
-
def default_layout
-
<<~HTML
-
<!DOCTYPE html>
-
<html>
-
<head>
-
<title>{{ page.title }}</title>
-
<meta name="description" content="{{ page.description }}">
-
<meta name="viewport" content="width=device-width, initial-scale=1">
-
<style>
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; background: #f9fafb; }
-
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
-
</style>
-
</head>
-
<body>
-
<div class="container">
-
{{ content_for_layout }}
-
</div>
-
</body>
-
</html>
-
HTML
-
end
-
end
-
-
-
class MaxmindUpdaterService
-
include Singleton
-
-
MAXMIND_BASE_URL = 'https://download.maxmind.com/app/geoip_download'
-
DATABASE_DIR = Rails.root.join('db', 'maxmind')
-
-
DATABASES = {
-
'country' => {
-
edition_id: 'GeoLite2-Country',
-
filename: 'GeoLite2-Country.mmdb'
-
},
-
'city' => {
-
edition_id: 'GeoLite2-City',
-
filename: 'GeoLite2-City.mmdb'
-
}
-
}.freeze
-
-
def initialize
-
ensure_database_directory
-
end
-
-
def update_database(type = 'country')
-
return { success: false, message: 'MaxMind license key not configured' } unless license_key_configured?
-
return { success: false, message: 'Invalid database type' } unless DATABASES.key?(type)
-
-
database_info = DATABASES[type]
-
download_url = build_download_url(database_info[:edition_id])
-
target_path = DATABASE_DIR.join(database_info[:filename])
-
temp_path = target_path.to_s + '.tmp'
-
-
begin
-
Rails.logger.info "Starting MaxMind #{type} database update..."
-
-
# Download the database
-
response = download_database(download_url)
-
return { success: false, message: "Download failed: #{response[:error]}" } unless response[:success]
-
-
# Write to temporary file
-
File.write(temp_path, response[:data])
-
-
# Verify the downloaded file
-
unless valid_mmdb_file?(temp_path)
-
File.delete(temp_path) if File.exist?(temp_path)
-
return { success: false, message: 'Downloaded file is not a valid MMDB database' }
-
end
-
-
# Backup existing database if it exists
-
if File.exist?(target_path)
-
backup_path = target_path.to_s + ".backup.#{Time.current.to_i}"
-
File.rename(target_path, backup_path)
-
end
-
-
# Move temp file to final location
-
File.rename(temp_path, target_path)
-
-
# Clean up backup if everything is successful
-
if File.exist?(backup_path)
-
File.delete(backup_path)
-
end
-
-
# Update last update timestamp
-
update_last_update_timestamp(type)
-
-
Rails.logger.info "MaxMind #{type} database updated successfully"
-
{ success: true, message: "#{type.capitalize} database updated successfully", path: target_path }
-
-
rescue => e
-
Rails.logger.error "MaxMind database update failed: #{e.message}"
-
-
# Clean up temp file
-
File.delete(temp_path) if File.exist?(temp_path)
-
-
{ success: false, message: "Update failed: #{e.message}" }
-
end
-
end
-
-
def update_all_databases
-
results = {}
-
-
DATABASES.keys.each do |type|
-
results[type] = update_database(type)
-
end
-
-
results
-
end
-
-
def check_database_age(type = 'country')
-
return { error: 'Invalid database type' } unless DATABASES.key?(type)
-
-
database_info = DATABASES[type]
-
target_path = DATABASE_DIR.join(database_info[:filename])
-
-
if File.exist?(target_path)
-
stat = File.stat(target_path)
-
age_days = (Time.current - stat.mtime) / 1.day
-
-
{
-
exists: true,
-
age_days: age_days.round(1),
-
last_modified: stat.mtime,
-
size: stat.size,
-
needs_update: age_days > 30 # MaxMind recommends updating monthly
-
}
-
else
-
{
-
exists: false,
-
needs_update: true
-
}
-
end
-
end
-
-
def schedule_auto_update
-
return { success: false, message: 'Auto-update not enabled' } unless auto_update_enabled?
-
-
# Check if databases need updating
-
needs_update = false
-
DATABASES.keys.each do |type|
-
age_info = check_database_age(type)
-
if age_info[:needs_update]
-
needs_update = true
-
break
-
end
-
end
-
-
return { success: true, message: 'Databases are up to date' } unless needs_update
-
-
# Update databases in background
-
Thread.new do
-
begin
-
update_all_databases
-
Rails.logger.info "Scheduled MaxMind database update completed"
-
rescue => e
-
Rails.logger.error "Scheduled MaxMind database update failed: #{e.message}"
-
end
-
end
-
-
{ success: true, message: 'Auto-update scheduled' }
-
end
-
-
def test_connection
-
return { success: false, message: 'MaxMind license key not configured' } unless license_key_configured?
-
-
begin
-
# Test download with a small request
-
test_url = build_download_url('GeoLite2-Country', suffix: '.tar.gz')
-
response = HTTP.timeout(10).get(test_url)
-
-
if response.status == 200
-
{ success: true, message: 'Connection to MaxMind successful' }
-
else
-
{ success: false, message: "HTTP #{response.status}: #{response.reason}" }
-
end
-
rescue => e
-
{ success: false, message: "Connection failed: #{e.message}" }
-
end
-
end
-
-
def database_info
-
info = {}
-
-
DATABASES.each do |type, config|
-
target_path = DATABASE_DIR.join(config[:filename])
-
-
if File.exist?(target_path)
-
stat = File.stat(target_path)
-
info[type] = {
-
exists: true,
-
path: target_path,
-
size: stat.size,
-
last_modified: stat.mtime,
-
age_days: ((Time.current - stat.mtime) / 1.day).round(1),
-
needs_update: (Time.current - stat.mtime) > 30.days
-
}
-
else
-
info[type] = {
-
exists: false,
-
needs_update: true
-
}
-
end
-
end
-
-
info
-
end
-
-
private
-
-
def ensure_database_directory
-
FileUtils.mkdir_p(DATABASE_DIR) unless Dir.exist?(DATABASE_DIR)
-
end
-
-
def license_key_configured?
-
SiteSetting.get('maxmind_license_key', '').present?
-
end
-
-
def auto_update_enabled?
-
SiteSetting.get('maxmind_auto_update', false)
-
end
-
-
def build_download_url(edition_id, suffix = '.mmdb')
-
license_key = SiteSetting.get('maxmind_license_key', '')
-
"#{MAXMIND_BASE_URL}?edition_id=#{edition_id}&license_key=#{license_key}&suffix=#{suffix}"
-
end
-
-
def download_database(url)
-
begin
-
response = HTTP.timeout(30).get(url)
-
-
if response.status == 200
-
{ success: true, data: response.body.to_s }
-
else
-
{ success: false, error: "HTTP #{response.status}: #{response.reason}" }
-
end
-
rescue => e
-
{ success: false, error: e.message }
-
end
-
end
-
-
def valid_mmdb_file?(file_path)
-
begin
-
# Try to open the file with MaxMindDB to verify it's valid
-
db = MaxMindDB.new(file_path)
-
# If we can create the object without error, it's likely valid
-
true
-
rescue => e
-
Rails.logger.error "Invalid MMDB file: #{e.message}"
-
false
-
end
-
end
-
-
def update_last_update_timestamp(type)
-
SiteSetting.set("maxmind_#{type}_last_update", Time.current.iso8601)
-
end
-
-
# Scheduling methods for automatic updates
-
def schedule_auto_update(frequency = 'weekly')
-
# Schedule automatic updates using Sidekiq-Cron
-
cron_schedule = case frequency
-
when 'daily'
-
'0 2 * * *' # Daily at 2 AM
-
when 'weekly'
-
'0 2 * * 1' # Weekly on Monday at 2 AM
-
when 'monthly'
-
'0 2 1 * *' # Monthly on 1st at 2 AM
-
else
-
'0 2 * * 1' # Default to weekly
-
end
-
-
# Remove existing job if it exists
-
Sidekiq::Cron::Job.destroy('MaxMind Database Update')
-
-
# Create new scheduled job
-
Sidekiq::Cron::Job.create(
-
name: 'MaxMind Database Update',
-
cron: cron_schedule,
-
class: 'MaxmindUpdateJob',
-
args: ['full'],
-
description: "Automatic MaxMind database update (#{frequency})"
-
)
-
-
# Store the schedule preference
-
SiteSetting.set('maxmind_update_frequency', frequency)
-
SiteSetting.set('maxmind_auto_update_enabled', true)
-
-
Rails.logger.info "MaxMind automatic update scheduled: #{frequency} (#{cron_schedule})"
-
end
-
-
def disable_auto_update
-
# Remove the scheduled job
-
Sidekiq::Cron::Job.destroy('MaxMind Database Update')
-
-
# Update settings
-
SiteSetting.set('maxmind_auto_update_enabled', false)
-
-
Rails.logger.info "MaxMind automatic update disabled"
-
end
-
-
def get_update_schedule_info
-
job = Sidekiq::Cron::Job.find('MaxMind Database Update')
-
-
if job
-
{
-
enabled: true,
-
frequency: SiteSetting.get('maxmind_update_frequency', 'weekly'),
-
next_run: job.next_time,
-
last_run: get_last_update_time,
-
cron_schedule: job.cron
-
}
-
else
-
{
-
enabled: false,
-
frequency: nil,
-
next_run: nil,
-
last_run: get_last_update_time,
-
cron_schedule: nil
-
}
-
end
-
end
-
-
def get_last_update_time
-
last_update = SiteSetting.get('maxmind_last_update')
-
last_update ? Time.parse(last_update) : nil
-
end
-
-
def check_and_update_if_needed
-
# Check if databases need updating and update if necessary
-
needs_update = false
-
-
DATABASES.keys.each do |type|
-
age_info = check_database_age(type)
-
if age_info[:needs_update]
-
needs_update = true
-
break
-
end
-
end
-
-
if needs_update
-
Rails.logger.info "MaxMind databases need updating, starting automatic update"
-
MaxmindUpdateJob.perform_later('full')
-
else
-
Rails.logger.info "MaxMind databases are up to date"
-
end
-
end
-
end
-
class OauthProviderService
-
def self.configure_providers
-
# This method will be called to configure OAuth providers dynamically
-
# It will be used by the OAuth controller when settings are updated
-
-
# Clear existing providers
-
Rails.application.config.middleware.delete(OmniAuth::Builder)
-
-
# Add new providers based on current settings
-
Rails.application.config.middleware.use OmniAuth::Builder do
-
# Google OAuth
-
if SiteSetting.get('google_oauth_enabled', false) &&
-
SiteSetting.get('google_oauth_client_id', '').present? &&
-
SiteSetting.get('google_oauth_client_secret', '').present?
-
-
provider :google_oauth2,
-
SiteSetting.get('google_oauth_client_id', ''),
-
SiteSetting.get('google_oauth_client_secret', ''),
-
{
-
name: 'google',
-
scope: 'email,profile',
-
prompt: 'select_account',
-
access_type: 'offline',
-
hd: SiteSetting.get('google_oauth_tenant', '').presence
-
}
-
end
-
-
# GitHub OAuth
-
if SiteSetting.get('github_oauth_enabled', false) &&
-
SiteSetting.get('github_oauth_client_id', '').present? &&
-
SiteSetting.get('github_oauth_client_secret', '').present?
-
-
provider :github,
-
SiteSetting.get('github_oauth_client_id', ''),
-
SiteSetting.get('github_oauth_client_secret', ''),
-
{
-
scope: 'user:email'
-
}
-
end
-
-
# Facebook OAuth
-
if SiteSetting.get('facebook_oauth_enabled', false) &&
-
SiteSetting.get('facebook_oauth_app_id', '').present? &&
-
SiteSetting.get('facebook_oauth_app_secret', '').present?
-
-
provider :facebook,
-
SiteSetting.get('facebook_oauth_app_id', ''),
-
SiteSetting.get('facebook_oauth_app_secret', ''),
-
{
-
scope: 'email',
-
info_fields: 'email,name'
-
}
-
end
-
-
# Twitter OAuth
-
if SiteSetting.get('twitter_oauth_enabled', false) &&
-
SiteSetting.get('twitter_oauth_api_key', '').present? &&
-
SiteSetting.get('twitter_oauth_api_secret', '').present?
-
-
provider :twitter,
-
SiteSetting.get('twitter_oauth_api_key', ''),
-
SiteSetting.get('twitter_oauth_api_secret', '')
-
end
-
end
-
end
-
-
def self.get_available_providers
-
providers = []
-
-
providers << 'google' if SiteSetting.get('google_oauth_enabled', false) &&
-
SiteSetting.get('google_oauth_client_id', '').present?
-
-
providers << 'github' if SiteSetting.get('github_oauth_enabled', false) &&
-
SiteSetting.get('github_oauth_client_id', '').present?
-
-
providers << 'facebook' if SiteSetting.get('facebook_oauth_enabled', false) &&
-
SiteSetting.get('facebook_oauth_app_id', '').present?
-
-
providers << 'twitter' if SiteSetting.get('twitter_oauth_enabled', false) &&
-
SiteSetting.get('twitter_oauth_api_key', '').present?
-
-
providers
-
end
-
-
def self.provider_enabled?(provider)
-
case provider
-
when 'google'
-
SiteSetting.get('google_oauth_enabled', false) &&
-
SiteSetting.get('google_oauth_client_id', '').present?
-
when 'github'
-
SiteSetting.get('github_oauth_enabled', false) &&
-
SiteSetting.get('github_oauth_client_id', '').present?
-
when 'facebook'
-
SiteSetting.get('facebook_oauth_enabled', false) &&
-
SiteSetting.get('facebook_oauth_app_id', '').present?
-
when 'twitter'
-
SiteSetting.get('twitter_oauth_enabled', false) &&
-
SiteSetting.get('twitter_oauth_api_key', '').present?
-
else
-
false
-
end
-
end
-
end
-
class PluginReloadService
-
def self.reload_app_for_plugin_change(plugin_name, action)
-
return unless Rails.env.development?
-
-
Rails.logger.info "🔄 Plugin #{action}: #{plugin_name} - Triggering hot reload..."
-
-
# Store the reload data in the session for JavaScript to pick up
-
Thread.current[:plugin_reload_data] = {
-
plugin_name: plugin_name,
-
action: action,
-
timestamp: Time.current.to_i,
-
trigger_reload: true
-
}
-
-
# Also create a file trigger for any background processes
-
trigger_file = Rails.root.join('tmp', 'plugin_reload_trigger')
-
File.write(trigger_file, {
-
plugin_name: plugin_name,
-
action: action,
-
timestamp: Time.current.to_i
-
}.to_json)
-
-
Rails.logger.info "✅ Hot reload triggered for plugin #{action}: #{plugin_name}"
-
end
-
-
def self.get_reload_data
-
data = Thread.current[:plugin_reload_data]
-
Thread.current[:plugin_reload_data] = nil # Clear after reading
-
data
-
end
-
-
def self.trigger_hot_reload(plugin_name, action)
-
# This will be called from JavaScript to trigger the actual reload
-
Rails.logger.info "🔥 Hot reloading for plugin #{action}: #{plugin_name}"
-
-
# In a real hot reload system, this would:
-
# 1. Reload the plugin system
-
# 2. Update the UI without full page refresh
-
# 3. Show a success animation
-
-
# For now, we'll trigger a page reload with a cool animation
-
true
-
end
-
end
-
require 'net/imap'
-
require 'mail'
-
-
class PostByEmailService
-
class << self
-
def check_mail
-
return { new_posts: 0, checked: 0 } unless enabled?
-
-
new_posts = 0
-
checked = 0
-
-
imap = connect_to_imap
-
-
begin
-
imap.select(folder)
-
-
# Search for unread emails
-
message_ids = imap.search(['NOT', 'SEEN'])
-
checked = message_ids.length
-
-
Rails.logger.info "Found #{checked} unread email(s) in #{folder}"
-
-
message_ids.each do |message_id|
-
begin
-
# Fetch the email
-
msg_data = imap.fetch(message_id, 'RFC822')[0]
-
email = Mail.read_from_string(msg_data.attr['RFC822'])
-
-
# Create post from email
-
if create_post_from_email(email)
-
new_posts += 1
-
-
# Mark as read if configured
-
if mark_as_read?
-
imap.store(message_id, "+FLAGS", [:Seen])
-
end
-
-
# Delete if configured
-
if delete_after_import?
-
imap.store(message_id, "+FLAGS", [:Deleted])
-
end
-
end
-
rescue => e
-
Rails.logger.error "Error processing email #{message_id}: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
end
-
end
-
-
# Expunge deleted messages
-
imap.expunge if delete_after_import?
-
-
ensure
-
imap.disconnect if imap
-
end
-
-
{ new_posts: new_posts, checked: checked }
-
end
-
-
private
-
-
def enabled?
-
SiteSetting.get('post_by_email_enabled', false)
-
end
-
-
def server
-
SiteSetting.get('imap_server', '')
-
end
-
-
def port
-
SiteSetting.get('imap_port', '993').to_i
-
end
-
-
def email
-
SiteSetting.get('imap_email', '')
-
end
-
-
def password
-
SiteSetting.get('imap_password', '')
-
end
-
-
def ssl?
-
SiteSetting.get('imap_ssl', 'true') == 'true'
-
end
-
-
def folder
-
SiteSetting.get('imap_folder', 'INBOX')
-
end
-
-
def mark_as_read?
-
SiteSetting.get('post_by_email_mark_as_read', true)
-
end
-
-
def delete_after_import?
-
SiteSetting.get('post_by_email_delete_after_import', false)
-
end
-
-
def default_category_id
-
SiteSetting.get('post_by_email_default_category', nil)
-
end
-
-
def default_author_id
-
SiteSetting.get('post_by_email_default_author', User.first&.id)
-
end
-
-
def connect_to_imap
-
imap = Net::IMAP.new(server, port: port, ssl: ssl?)
-
imap.login(email, password)
-
imap
-
rescue => e
-
Rails.logger.error "Failed to connect to IMAP server: #{e.message}"
-
raise "IMAP connection failed: #{e.message}"
-
end
-
-
def create_post_from_email(email)
-
# Extract subject as title
-
title = email.subject.presence || "Post from #{email.from.first}"
-
-
# Extract body
-
body_html = extract_body(email)
-
-
# Find or create author
-
author = User.find_by(id: default_author_id) || User.first
-
-
unless author
-
Rails.logger.error "No author found for post by email"
-
return false
-
end
-
-
# Create the post
-
post = Post.new(
-
title: title,
-
body_html: body_html,
-
status: 'draft', # Always create as draft
-
user_id: author.id,
-
excerpt: generate_excerpt(body_html),
-
created_at: email.date || Time.current
-
)
-
-
# Assign category if configured
-
if default_category_id.present?
-
category = Term.for_taxonomy('category').find_by(id: default_category_id)
-
post.categories << category if category
-
end
-
-
if post.save
-
Rails.logger.info "Created post ##{post.id} from email: #{title}"
-
-
# Handle attachments
-
process_attachments(email, post) if email.attachments.any?
-
-
true
-
else
-
Rails.logger.error "Failed to create post from email: #{post.errors.full_messages.join(', ')}"
-
false
-
end
-
rescue => e
-
Rails.logger.error "Error creating post from email: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
false
-
end
-
-
def extract_body(email)
-
if email.html_part
-
# Prefer HTML if available
-
email.html_part.decoded
-
elsif email.text_part
-
# Convert plain text to HTML
-
text = email.text_part.decoded
-
text.gsub(/\n/, '<br>')
-
elsif email.body
-
# Fallback to body
-
body_content = email.body.decoded
-
-
# Check if it's HTML
-
if body_content =~ /<[^>]+>/
-
body_content
-
else
-
# Convert plain text to HTML
-
body_content.gsub(/\n/, '<br>')
-
end
-
else
-
''
-
end
-
rescue => e
-
Rails.logger.error "Error extracting email body: #{e.message}"
-
''
-
end
-
-
def generate_excerpt(html)
-
# Strip HTML tags and get first 150 characters
-
text = ActionView::Base.full_sanitizer.sanitize(html)
-
text.truncate(150, separator: ' ')
-
end
-
-
def process_attachments(email, post)
-
email.attachments.each do |attachment|
-
begin
-
# Skip if not an image (you can extend this to handle other types)
-
next unless attachment.content_type.start_with?('image/')
-
-
# Create a temporary file
-
tempfile = Tempfile.new([attachment.filename, File.extname(attachment.filename)])
-
tempfile.binmode
-
tempfile.write(attachment.decoded)
-
tempfile.rewind
-
-
# Attach to post using ActiveStorage
-
post.featured_image.attach(
-
io: tempfile,
-
filename: attachment.filename,
-
content_type: attachment.content_type
-
)
-
-
tempfile.close
-
tempfile.unlink
-
-
Rails.logger.info "Attached #{attachment.filename} to post ##{post.id}"
-
rescue => e
-
Rails.logger.error "Error processing attachment #{attachment.filename}: #{e.message}"
-
end
-
end
-
end
-
end
-
end
-
-
-
-
-
class RealtimeAnalyticsService
-
def self.broadcast_new_pageview(pageview)
-
data = {
-
type: 'new_pageview',
-
pageview: {
-
id: pageview.id,
-
path: pageview.path,
-
title: pageview.title,
-
country: pageview.country_name,
-
browser: pageview.browser,
-
device: pageview.device,
-
created_at: pageview.created_at.iso8601
-
},
-
stats: {
-
active_users: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
-
current_pageviews: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
-
unique_sessions: Pageview.where(created_at: 10.minutes.ago..Time.current).distinct.count(:session_id),
-
active_countries: Pageview.where(created_at: 10.minutes.ago..Time.current).where.not(country_name: [nil, '']).distinct.count(:country_name)
-
},
-
timestamp: Time.current.iso8601
-
}
-
-
ActionCable.server.broadcast('realtime_analytics', data)
-
end
-
-
def self.broadcast_stats_update
-
data = {
-
type: 'stats_update',
-
stats: {
-
active_users: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
-
current_pageviews: Pageview.where(created_at: 10.minutes.ago..Time.current).count,
-
unique_sessions: Pageview.where(created_at: 10.minutes.ago..Time.current).distinct.count(:session_id),
-
active_countries: Pageview.where(created_at: 10.minutes.ago..Time.current).where.not(country_name: [nil, '']).distinct.count(:country_name)
-
},
-
recent_views: Pageview.where(created_at: 10.minutes.ago..Time.current)
-
.order(created_at: :desc)
-
.limit(10)
-
.map do |pv|
-
{
-
path: pv.path,
-
country: pv.country_name,
-
browser: pv.browser,
-
device: pv.device,
-
created_at: pv.created_at.iso8601
-
}
-
end,
-
timestamp: Time.current.iso8601
-
}
-
-
ActionCable.server.broadcast('realtime_analytics', data)
-
end
-
end
-
require 'ferrum'
-
-
class ScreenshotService
-
attr_reader :url, :width, :height, :format
-
-
def initialize(url, options = {})
-
@url = url
-
@width = options[:width] || 1200
-
@height = options[:height] || 800
-
@format = options[:format] || :png
-
end
-
-
def capture
-
Rails.logger.info "ScreenshotService: Capturing screenshot of #{@url}"
-
-
browser = Ferrum::Browser.new(
-
headless: true,
-
window_size: [@width, @height],
-
timeout: 15, # Reduced timeout
-
process_timeout: 10, # Add process timeout
-
slow_mo: 0, # No slow motion
-
browser_options: {
-
'no-sandbox' => nil,
-
'disable-dev-shm-usage' => nil,
-
'disable-gpu' => nil,
-
'disable-extensions' => nil,
-
'disable-plugins' => nil,
-
'disable-web-security' => nil,
-
'disable-features' => 'VizDisplayCompositor'
-
}
-
)
-
-
begin
-
Rails.logger.info "ScreenshotService: Browser created, navigating to #{@url}"
-
-
# Navigate with reduced timeout
-
browser.go_to(@url)
-
-
# Check if we got redirected to login page
-
current_url = browser.current_url
-
Rails.logger.info "ScreenshotService: Current URL after navigation: #{current_url}"
-
-
if current_url.include?('/auth/sign_in')
-
Rails.logger.info "ScreenshotService: Redirected to login, this is expected for admin routes"
-
raise "Cannot capture screenshot of admin route - authentication required"
-
end
-
-
# Wait for page to fully load
-
Rails.logger.info "ScreenshotService: Waiting for page to load completely"
-
sleep(2) # Give the page time to render
-
-
# Take screenshot
-
Rails.logger.info "ScreenshotService: Taking screenshot with format #{@format}"
-
screenshot_data = browser.screenshot(
-
format: @format,
-
full: false
-
)
-
-
Rails.logger.info "ScreenshotService: Screenshot captured successfully"
-
screenshot_data
-
rescue => e
-
Rails.logger.error "ScreenshotService: Error during capture - #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
raise e
-
ensure
-
browser.quit
-
end
-
end
-
-
def capture_and_save(file_path)
-
screenshot_data = capture
-
-
# Ensure directory exists
-
FileUtils.mkdir_p(File.dirname(file_path))
-
-
# Save screenshot
-
File.write(file_path, screenshot_data)
-
-
file_path
-
end
-
-
# Capture theme preview screenshot
-
def self.capture_theme_preview(theme_name, options = {})
-
url = Rails.application.routes.url_helpers.preview_admin_themes_url(theme: theme_name, host: 'localhost:3000')
-
-
screenshot_service = new(url, options)
-
-
# Generate filename
-
filename = "screenshot_#{theme_name.downcase}_#{Time.current.strftime('%Y%m%d_%H%M%S')}.#{options[:format] || 'png'}"
-
file_path = Rails.root.join('tmp', 'screenshots', filename)
-
-
screenshot_service.capture_and_save(file_path)
-
end
-
-
# Capture theme screenshot and return data directly (no filesystem storage)
-
def self.capture_theme_screenshot_data(theme, options = {})
-
Rails.logger.info "ScreenshotService: capture_theme_screenshot_data called with theme: #{theme.inspect}"
-
return nil unless theme
-
-
# Handle both Theme model objects and hash objects
-
theme_id = theme.respond_to?(:id) ? theme.id : theme[:id]
-
theme_name = theme.respond_to?(:name) ? theme.name : theme[:name]
-
Rails.logger.info "ScreenshotService: Theme ID: #{theme_id}, Name: #{theme_name}"
-
-
# Use optimized options for faster screenshots
-
optimized_options = {
-
width: 800, # Smaller width for faster processing
-
height: 600, # Smaller height for faster processing
-
format: :png
-
}.merge(options)
-
-
# Use the working public preview route with theme ID
-
public_preview_url = Rails.application.routes.url_helpers.theme_preview_url(host: 'localhost:3000', id: theme_id)
-
Rails.logger.info "ScreenshotService: Using public preview URL: #{public_preview_url}"
-
-
screenshot_service = new(public_preview_url, optimized_options)
-
screenshot_service.capture
-
end
-
-
-
end
-
class StorageConfigurationService
-
attr_reader :storage_settings, :tenant
-
-
def initialize(tenant = nil)
-
@tenant = tenant || ActsAsTenant.current_tenant
-
@storage_settings = load_storage_settings
-
end
-
-
# Configure ActiveStorage based on storage settings
-
def configure_active_storage
-
case storage_settings[:storage_type]
-
when 's3'
-
configure_s3_storage
-
when 'local'
-
configure_local_storage
-
else
-
configure_local_storage # Default fallback
-
end
-
end
-
-
# Get the appropriate storage service name
-
def storage_service_name
-
case storage_settings[:storage_type]
-
when 's3'
-
'amazon'
-
when 'local'
-
'local'
-
else
-
'local'
-
end
-
end
-
-
# Get the storage root path for local storage
-
def local_storage_root
-
storage_settings[:local_storage_path] || Rails.root.join('storage').to_s
-
end
-
-
# Check if CDN is enabled
-
def cdn_enabled?
-
storage_settings[:enable_cdn] && storage_settings[:cdn_url].present?
-
end
-
-
# Get CDN URL
-
def cdn_url
-
storage_settings[:cdn_url] if cdn_enabled?
-
end
-
-
# Check if auto-optimization is enabled
-
def auto_optimize_enabled?
-
storage_settings[:auto_optimize_uploads]
-
end
-
-
# Get max file size in bytes
-
def max_file_size_bytes
-
storage_settings[:max_file_size] * 1024 * 1024 # Convert MB to bytes
-
end
-
-
# Get allowed file types as array
-
def allowed_file_types
-
return [] unless storage_settings[:allowed_file_types].present?
-
storage_settings[:allowed_file_types].split(',').map(&:strip).map(&:downcase)
-
end
-
-
# Validate file against storage settings
-
def file_allowed?(file)
-
return false if file.nil?
-
-
# Check file size
-
return false if file.size > max_file_size_bytes
-
-
# Check file extension
-
extension = File.extname(file.original_filename).downcase.gsub('.', '')
-
return false unless allowed_file_types.include?(extension)
-
-
true
-
end
-
-
# Get S3 configuration
-
def s3_config
-
return {} unless storage_settings[:storage_type] == 's3'
-
-
{
-
service: 'S3',
-
access_key_id: storage_settings[:storage_access_key],
-
secret_access_key: storage_settings[:storage_secret_key],
-
region: storage_settings[:storage_region] || 'us-east-1',
-
bucket: storage_settings[:storage_bucket],
-
endpoint: storage_settings[:storage_endpoint],
-
path: storage_settings[:storage_path]
-
}.compact
-
end
-
-
# Update storage.yml configuration
-
def update_storage_config
-
storage_yml_path = Rails.root.join('config', 'storage.yml')
-
-
# Read current configuration
-
current_config = File.exist?(storage_yml_path) ? YAML.load_file(storage_yml_path) : {}
-
-
# Update configuration based on storage type
-
case storage_settings[:storage_type]
-
when 's3'
-
current_config['amazon'] = s3_config
-
current_config['local'] = {
-
'service' => 'Disk',
-
'root' => local_storage_root
-
}
-
when 'local'
-
current_config['local'] = {
-
'service' => 'Disk',
-
'root' => local_storage_root
-
}
-
end
-
-
# Write updated configuration
-
File.write(storage_yml_path, current_config.to_yaml)
-
-
# Reload ActiveStorage configuration
-
Rails.application.config.active_storage.service = storage_service_name.to_sym
-
end
-
-
private
-
-
def load_storage_settings
-
# Get current tenant storage settings if available
-
tenant_settings = {}
-
if @tenant
-
tenant_settings = {
-
storage_type: @tenant.storage_type || 'local',
-
storage_bucket: @tenant.storage_bucket,
-
storage_region: @tenant.storage_region,
-
storage_access_key: @tenant.storage_access_key,
-
storage_secret_key: @tenant.storage_secret_key,
-
storage_endpoint: @tenant.storage_endpoint,
-
storage_path: @tenant.storage_path
-
}
-
end
-
-
# Merge with SiteSetting values
-
{
-
# Storage Type
-
storage_type: tenant_settings[:storage_type] || SiteSetting.get('storage_type', 'local'),
-
-
# Local Storage Configuration
-
local_storage_path: SiteSetting.get('local_storage_path', Rails.root.join('storage').to_s),
-
-
# S3 Configuration
-
storage_bucket: tenant_settings[:storage_bucket] || SiteSetting.get('storage_bucket', ''),
-
storage_region: tenant_settings[:storage_region] || SiteSetting.get('storage_region', 'us-east-1'),
-
storage_access_key: tenant_settings[:storage_access_key] || SiteSetting.get('storage_access_key', ''),
-
storage_secret_key: tenant_settings[:storage_secret_key] || SiteSetting.get('storage_secret_key', ''),
-
storage_endpoint: tenant_settings[:storage_endpoint] || SiteSetting.get('storage_endpoint', ''),
-
storage_path: tenant_settings[:storage_path] || SiteSetting.get('storage_path', ''),
-
-
# General Storage Settings
-
enable_cdn: SiteSetting.get('enable_cdn', false),
-
cdn_url: SiteSetting.get('cdn_url', ''),
-
auto_optimize_uploads: SiteSetting.get('auto_optimize_uploads', true),
-
max_file_size: SiteSetting.get('max_file_size', 10).to_i, # MB
-
allowed_file_types: SiteSetting.get('allowed_file_types', 'jpg,jpeg,png,gif,pdf,doc,docx,mp4,mp3')
-
}
-
end
-
-
def configure_s3_storage
-
# S3 configuration is handled by the s3_config method
-
# This could be extended to set up S3-specific settings
-
end
-
-
def configure_local_storage
-
# Ensure local storage directory exists
-
storage_path = local_storage_root
-
FileUtils.mkdir_p(storage_path) unless File.directory?(storage_path)
-
-
# Set proper permissions
-
FileUtils.chmod(0755, storage_path)
-
end
-
end
-
class ThemeFileManager
-
# Allowed file extensions for editing
-
EDITABLE_EXTENSIONS = %w[
-
.erb .html .htm .haml .slim .liquid .php
-
.css .scss .sass
-
.js .coffee
-
.json .yml .yaml
-
.rb
-
.md .txt
-
].freeze
-
-
# Binary/asset extensions (download only)
-
BINARY_EXTENSIONS = %w[
-
.png .jpg .jpeg .gif .svg .webp .ico
-
.woff .woff2 .ttf .eot .otf
-
.mp4 .webm .ogg
-
.zip .tar .gz
-
.pdf
-
].freeze
-
-
attr_reader :theme_name, :theme_path, :errors
-
-
def initialize(theme_name)
-
@theme_name = theme_name
-
@theme_path = Rails.root.join('app', 'themes', theme_name)
-
@errors = []
-
-
validate_theme_exists!
-
end
-
-
# List all files in theme directory
-
def list_files(directory = '')
-
return [] unless valid_directory?(directory)
-
-
full_path = @theme_path.join(directory)
-
entries = []
-
-
Dir.entries(full_path).sort.each do |entry|
-
next if entry.start_with?('.')
-
-
entry_path = full_path.join(entry)
-
relative_path = entry_path.relative_path_from(@theme_path).to_s
-
-
entries << {
-
name: entry,
-
path: relative_path,
-
type: File.directory?(entry_path) ? 'directory' : 'file',
-
editable: editable_file?(entry),
-
extension: File.extname(entry),
-
size: File.directory?(entry_path) ? nil : File.size(entry_path),
-
modified_at: File.mtime(entry_path)
-
}
-
end
-
-
entries
-
end
-
-
# Get file tree structure
-
def file_tree
-
build_tree(@theme_path)
-
end
-
-
# Read file content
-
def read_file(file_path)
-
return nil unless valid_file_path?(file_path)
-
-
full_path = @theme_path.join(file_path)
-
-
unless File.exist?(full_path)
-
@errors << "File not found: #{file_path}"
-
return nil
-
end
-
-
unless editable_file?(file_path)
-
@errors << "File type not editable: #{file_path}"
-
return nil
-
end
-
-
File.read(full_path)
-
end
-
-
# Write file content
-
def write_file(file_path, content)
-
return false unless valid_file_path?(file_path)
-
-
full_path = @theme_path.join(file_path)
-
-
unless editable_file?(file_path)
-
@errors << "File type not editable: #{file_path}"
-
return false
-
end
-
-
# Create backup before writing
-
create_backup(full_path) if File.exist?(full_path)
-
-
# Ensure directory exists
-
FileUtils.mkdir_p(File.dirname(full_path))
-
-
# Write file
-
File.write(full_path, content)
-
-
# Create version record
-
create_version_record(file_path, content)
-
-
true
-
rescue => e
-
@errors << "Failed to write file: #{e.message}"
-
false
-
end
-
-
# Create new file
-
def create_file(file_path, content = '')
-
return false unless valid_file_path?(file_path)
-
-
full_path = @theme_path.join(file_path)
-
-
if File.exist?(full_path)
-
@errors << "File already exists: #{file_path}"
-
return false
-
end
-
-
write_file(file_path, content)
-
end
-
-
# Delete file
-
def delete_file(file_path)
-
return false unless valid_file_path?(file_path)
-
-
full_path = @theme_path.join(file_path)
-
-
unless File.exist?(full_path)
-
@errors << "File not found: #{file_path}"
-
return false
-
end
-
-
# Create backup before deleting
-
create_backup(full_path)
-
-
File.delete(full_path)
-
true
-
rescue => e
-
@errors << "Failed to delete file: #{e.message}"
-
false
-
end
-
-
# Rename file
-
def rename_file(old_path, new_path)
-
return false unless valid_file_path?(old_path) && valid_file_path?(new_path)
-
-
old_full_path = @theme_path.join(old_path)
-
new_full_path = @theme_path.join(new_path)
-
-
unless File.exist?(old_full_path)
-
@errors << "File not found: #{old_path}"
-
return false
-
end
-
-
if File.exist?(new_full_path)
-
@errors << "File already exists: #{new_path}"
-
return false
-
end
-
-
FileUtils.mv(old_full_path, new_full_path)
-
true
-
rescue => e
-
@errors << "Failed to rename file: #{e.message}"
-
false
-
end
-
-
# Search in files
-
def search(query)
-
return [] if query.blank?
-
-
results = []
-
-
Dir.glob(@theme_path.join('**', '*')).each do |file_path|
-
next unless File.file?(file_path)
-
next unless editable_file?(file_path)
-
-
begin
-
content = File.read(file_path)
-
relative_path = Pathname.new(file_path).relative_path_from(@theme_path).to_s
-
-
content.each_line.with_index do |line, line_number|
-
if line.include?(query)
-
results << {
-
file: relative_path,
-
line: line_number + 1,
-
content: line.strip,
-
match: line.index(query)
-
}
-
end
-
end
-
rescue => e
-
# Skip files that can't be read
-
end
-
end
-
-
results
-
end
-
-
# Get file versions
-
def file_versions(file_path)
-
ThemeFileVersion.where(
-
theme_name: @theme_name,
-
file_path: file_path
-
).order(created_at: :desc).limit(20)
-
end
-
-
# Restore file from version
-
def restore_version(version_id)
-
version = ThemeFileVersion.find(version_id)
-
-
return false unless version.theme_name == @theme_name
-
-
write_file(version.file_path, version.content)
-
end
-
-
private
-
-
def validate_theme_exists!
-
unless File.directory?(@theme_path)
-
raise ArgumentError, "Theme not found: #{@theme_name}"
-
end
-
end
-
-
def valid_directory?(directory)
-
# Prevent directory traversal
-
return false if directory.include?('..')
-
return false if directory.start_with?('/')
-
-
true
-
end
-
-
def valid_file_path?(file_path)
-
# Prevent path traversal attacks
-
return false if file_path.include?('..')
-
return false if file_path.start_with?('/')
-
-
# Must be within theme directory
-
full_path = @theme_path.join(file_path)
-
return false unless full_path.to_s.start_with?(@theme_path.to_s)
-
-
true
-
end
-
-
def editable_file?(file_path)
-
ext = File.extname(file_path).downcase
-
EDITABLE_EXTENSIONS.include?(ext)
-
end
-
-
def build_tree(directory, prefix = '')
-
entries = []
-
-
Dir.entries(directory).sort.each do |entry|
-
next if entry.start_with?('.')
-
-
entry_path = File.join(directory, entry)
-
relative_path = Pathname.new(entry_path).relative_path_from(@theme_path).to_s
-
-
if File.directory?(entry_path)
-
entries << {
-
name: entry,
-
path: relative_path,
-
type: 'directory',
-
children: build_tree(entry_path, "#{prefix}#{entry}/")
-
}
-
else
-
entries << {
-
name: entry,
-
path: relative_path,
-
type: 'file',
-
editable: editable_file?(entry),
-
extension: File.extname(entry),
-
size: File.size(entry_path)
-
}
-
end
-
end
-
-
entries
-
end
-
-
def create_backup(file_path)
-
backup_dir = Rails.root.join('tmp', 'theme_backups', @theme_name)
-
FileUtils.mkdir_p(backup_dir)
-
-
timestamp = Time.current.strftime('%Y%m%d_%H%M%S')
-
backup_file = backup_dir.join("#{File.basename(file_path)}.#{timestamp}.bak")
-
-
FileUtils.cp(file_path, backup_file)
-
end
-
-
def create_version_record(file_path, content)
-
ThemeFileVersion.create!(
-
theme_name: @theme_name,
-
file_path: file_path,
-
content: content,
-
file_size: content.bytesize,
-
user_id: nil # TODO: Pass user from controller
-
)
-
rescue => e
-
Rails.logger.error "Failed to create version record: #{e.message}"
-
end
-
end
-
-
-
-
-
-
-
-
-
class ThemePreviewRenderer
-
attr_reader :published_version, :builder_renderer
-
-
def initialize(builder_theme, template_name = 'index')
-
@builder_theme = builder_theme
-
@template_name = template_name
-
@theme_preview = ThemePreview.find_or_create_for_builder(builder_theme, template_name)
-
-
# Use published version for base files (layout, assets)
-
@published_version = builder_theme.published_version
-
-
# Create a mock BuilderTheme for the existing BuilderLiquidRenderer
-
@mock_builder_theme = create_mock_builder_theme
-
Rails.logger.info "Created mock builder theme for ThemePreviewRenderer"
-
-
@builder_renderer = BuilderLiquidRenderer.new(@mock_builder_theme)
-
Rails.logger.info "Created BuilderLiquidRenderer for preview"
-
end
-
-
# Render a template with all sections, header, footer, etc.
-
def render
-
# Use the existing BuilderLiquidRenderer
-
html = @builder_renderer.render_template(@template_name)
-
-
# Replace asset URLs with embedded content for preview
-
html = replace_asset_urls_with_content(html)
-
-
html
-
rescue => e
-
Rails.logger.error "ThemePreviewRenderer error: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
"<div class='error'>ThemePreviewRenderer Error: #{e.message}<br>Backtrace: #{e.backtrace.first(5).join('<br>')}</div>"
-
end
-
-
-
# Get CSS and JS assets including all sections
-
def assets
-
# Use the existing BuilderLiquidRenderer's assets method
-
@builder_renderer.assets
-
end
-
-
private
-
-
def create_mock_builder_theme
-
# Create a mock BuilderTheme object that delegates to ThemePreview data
-
mock_theme = Object.new
-
-
# Define methods that BuilderLiquidRenderer expects
-
def mock_theme.get_rendered_file(template_name)
-
# Return the template data from ThemePreview (not PublishedThemeFile)
-
template_content = @theme_preview.template_content
-
-
# Get layout file from PublishedThemeFile (base files)
-
layout_file = @published_version.published_theme_files.find_by(file_path: 'layout/theme.liquid')
-
layout_content = layout_file&.content || default_layout
-
-
# Build page sections from ThemePreview data
-
page_sections = []
-
template_content['order']&.each_with_index do |section_id, index|
-
section_config = template_content['sections'][section_id]
-
next unless section_config
-
-
# Create a mock section object with blocks support
-
section = Object.new
-
def section.section_id
-
@section_id
-
end
-
def section.section_type
-
@section_type
-
end
-
def section.settings
-
@settings
-
end
-
def section.position
-
@position
-
end
-
def section.blocks
-
@blocks || []
-
end
-
-
section.instance_variable_set(:@section_id, section_id)
-
section.instance_variable_set(:@section_type, section_config['type'])
-
section.instance_variable_set(:@settings, section_config['settings'] || {})
-
section.instance_variable_set(:@position, index)
-
-
# Add blocks support if the section has blocks
-
if section_config['blocks']
-
blocks = section_config['blocks'].map do |block_data|
-
block = Object.new
-
def block.id
-
@id
-
end
-
def block.type
-
@type
-
end
-
def block.settings
-
@settings
-
end
-
-
block.instance_variable_set(:@id, block_data['id'] || SecureRandom.hex(8))
-
block.instance_variable_set(:@type, block_data['type'])
-
block.instance_variable_set(:@settings, block_data['settings'] || {})
-
block
-
end
-
section.instance_variable_set(:@blocks, blocks)
-
end
-
-
page_sections << section
-
end
-
-
{
-
template_name: template_name,
-
template_content: template_content,
-
layout_content: layout_content,
-
theme_settings: {},
-
page_sections: page_sections
-
}
-
end
-
-
# Store the theme_preview, published_version, and builder_theme for access in methods
-
mock_theme.instance_variable_set(:@theme_preview, @theme_preview)
-
mock_theme.instance_variable_set(:@published_version, @published_version)
-
mock_theme.instance_variable_set(:@builder_theme, @builder_theme)
-
-
# Add other methods that might be needed
-
def mock_theme.theme_name
-
@published_version.theme.name.underscore
-
end
-
-
def mock_theme.id
-
@builder_theme.id
-
end
-
-
mock_theme
-
end
-
-
def default_layout
-
<<~HTML
-
<!DOCTYPE html>
-
<html lang="en">
-
<head>
-
<meta charset="UTF-8">
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
-
<title>{{ page.title | default: site.title }}</title>
-
</head>
-
<body>
-
{{ content_for_layout }}
-
</body>
-
</html>
-
HTML
-
end
-
-
-
def replace_asset_urls_with_content(html)
-
# Get assets from published theme files
-
published_version = @builder_theme.published_version
-
-
# Get CSS content
-
css_file = published_version.published_theme_files.find_by(file_path: 'assets/theme.css')
-
css_content = css_file&.content || ''
-
-
# Get JS content
-
js_file = published_version.published_theme_files.find_by(file_path: 'assets/theme.js')
-
js_content = js_file&.content || ''
-
-
# Replace CSS link tags with embedded styles
-
html = html.gsub(/<link[^>]*href="[^"]*\/theme\.css"[^>]*>/) do |match|
-
if css_content.present?
-
"<style>#{css_content}</style>"
-
else
-
match # Keep original if no CSS
-
end
-
end
-
-
# Replace JS script tags with embedded scripts
-
html = html.gsub(/<script[^>]*src="[^"]*\/theme\.js"[^>]*><\/script>/) do |match|
-
if js_content.present?
-
"<script>#{js_content}</script>"
-
else
-
match # Keep original if no JS
-
end
-
end
-
-
html
-
rescue => e
-
Rails.logger.error "Error in replace_asset_urls_with_content: #{e.message}"
-
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
-
html # Return original HTML if there's an error
-
end
-
end
-
class ThemeVersionLoader
-
class << self
-
def current_theme_version
-
@current_theme_version ||= ThemeVersion.live.for_theme(current_theme_name).first
-
end
-
-
def current_theme_name
-
@current_theme_name ||= Railspress::ThemeLoader.current_theme
-
end
-
-
def load_template(template_type)
-
if current_theme_version
-
current_theme_version.template_data(template_type)
-
else
-
load_base_template(template_type)
-
end
-
end
-
-
def load_section(section_type)
-
if current_theme_version
-
current_theme_version.section_content(section_type)
-
else
-
load_base_section(section_type)
-
end
-
end
-
-
def load_layout
-
if current_theme_version
-
current_theme_version.layout_content
-
else
-
load_base_layout
-
end
-
end
-
-
def load_assets
-
if current_theme_version
-
current_theme_version.assets
-
else
-
load_base_assets
-
end
-
end
-
-
def render_template(template_type, context = {})
-
template_data = load_template(template_type)
-
layout_content = load_layout
-
-
# Render sections from template data
-
sections_html = render_sections_from_template_data(template_data, context)
-
-
# Combine with layout
-
layout_content.gsub('{{ content_for_layout }}', sections_html)
-
end
-
-
private
-
-
def render_sections_from_template_data(template_data, context)
-
return '' unless template_data['order'] && template_data['sections']
-
-
sections_html = ''
-
template_data['order'].each do |section_id|
-
section_data = template_data['sections'][section_id]
-
next unless section_data
-
-
section_content = load_section(section_data['type'])
-
next unless section_content
-
-
# Create liquid template
-
template = Liquid::Template.parse(section_content)
-
-
# Prepare context
-
liquid_context = {
-
'section' => {
-
'settings' => section_data['settings'] || {},
-
'id' => section_id,
-
'type' => section_data['type']
-
}
-
}.merge(context)
-
-
# Render section
-
sections_html += template.render(liquid_context)
-
end
-
-
sections_html
-
rescue => e
-
Rails.logger.error "Error rendering template: #{e.message}"
-
''
-
end
-
-
def load_base_template(template_type)
-
theme_path = Rails.root.join('app', 'themes', current_theme_name)
-
template_file = theme_path.join('templates', "#{template_type}.json")
-
-
if File.exist?(template_file)
-
JSON.parse(File.read(template_file))
-
else
-
{ 'sections' => {}, 'order' => [] }
-
end
-
end
-
-
def load_base_section(section_type)
-
theme_path = Rails.root.join('app', 'themes', current_theme_name)
-
section_file = theme_path.join('sections', "#{section_type}.liquid")
-
-
File.exist?(section_file) ? File.read(section_file) : ''
-
end
-
-
def load_base_layout
-
theme_path = Rails.root.join('app', 'themes', current_theme_name)
-
layout_file = theme_path.join('layout', 'theme.liquid')
-
-
if File.exist?(layout_file)
-
File.read(layout_file)
-
else
-
default_layout
-
end
-
end
-
-
def load_base_assets
-
theme_path = Rails.root.join('app', 'themes', current_theme_name)
-
-
{
-
css: load_asset_file(theme_path, 'assets/theme.css'),
-
js: load_asset_file(theme_path, 'assets/theme.js')
-
}
-
end
-
-
def load_asset_file(theme_path, asset_path)
-
asset_file = theme_path.join(asset_path)
-
File.exist?(asset_file) ? File.read(asset_file) : ''
-
end
-
-
def default_layout
-
<<~LIQUID
-
<!DOCTYPE html>
-
<html>
-
<head>
-
<title>{{ page.title }}</title>
-
<meta name="description" content="{{ page.description }}">
-
<style>
-
{{ assets.css }}
-
</style>
-
</head>
-
<body>
-
{{ content_for_layout }}
-
<script>
-
{{ assets.js }}
-
</script>
-
</body>
-
</html>
-
LIQUID
-
end
-
end
-
end
-
-
-
-
-
class ThemeVersionService
-
def initialize(theme_version)
-
@theme_version = theme_version
-
@theme_name = theme_version.theme_name
-
@theme_path = Rails.root.join('app', 'themes', @theme_name)
-
end
-
-
def snapshot_theme_files
-
return false unless Dir.exist?(@theme_path)
-
-
# Create file versions for all theme files
-
snapshot_directory_recursive(@theme_path.to_s, '')
-
true
-
rescue => e
-
Rails.logger.error "Error snapshotting theme files: #{e.message}"
-
false
-
end
-
-
def update_file(file_path, content)
-
# Create or find the theme file
-
theme_file = ThemeFile.find_or_create_from_path(@theme_name, file_path)
-
-
# Create a new version linked to this theme version
-
ThemeFileVersion.create_version(@theme_name, file_path, content, @theme_version.user, @theme_version)
-
end
-
-
def update_template(template_type, template_data)
-
file_path = "templates/#{template_type}.json"
-
content = JSON.pretty_generate(template_data)
-
update_file(file_path, content)
-
end
-
-
def update_section(section_type, content)
-
file_path = "sections/#{section_type}.liquid"
-
update_file(file_path, content)
-
end
-
-
def update_layout(content)
-
file_path = "layout/theme.liquid"
-
update_file(file_path, content)
-
end
-
-
def update_asset(asset_type, content)
-
file_path = "assets/#{asset_type}"
-
update_file(file_path, content)
-
end
-
-
def get_file_content(file_path)
-
@theme_version.theme_file_versions.find_by(file_path: file_path)&.content
-
end
-
-
def get_template_data(template_type)
-
content = get_file_content("templates/#{template_type}.json")
-
content ? JSON.parse(content) : {}
-
rescue JSON::ParserError
-
{}
-
end
-
-
def get_section_content(section_type)
-
get_file_content("sections/#{section_type}.liquid") || ''
-
end
-
-
def get_layout_content
-
get_file_content("layout/theme.liquid") || ''
-
end
-
-
def get_assets
-
{
-
css: get_file_content("assets/theme.css") || '',
-
js: get_file_content("assets/theme.js") || ''
-
}
-
end
-
-
def render_preview(template_type)
-
renderer = LiquidTemplateVersionRenderer.new(@theme_version, template_type)
-
renderer.render
-
end
-
-
private
-
-
def snapshot_directory_recursive(source_dir, relative_path)
-
Dir.entries(source_dir).each do |entry|
-
next if entry.start_with?('.')
-
next if entry == 'node_modules'
-
-
source_path = File.join(source_dir, entry)
-
relative_file_path = relative_path.blank? ? entry : File.join(relative_path, entry)
-
-
if File.directory?(source_path)
-
snapshot_directory_recursive(source_path, relative_file_path)
-
else
-
snapshot_file(source_path, relative_file_path)
-
end
-
end
-
end
-
-
def snapshot_file(source_path, relative_path)
-
return unless should_snapshot_file?(relative_path)
-
-
content = File.read(source_path)
-
-
# Create or find the theme file
-
theme_file = ThemeFile.find_or_create_from_path(@theme_name, relative_path)
-
-
# Create a new version linked to this theme version
-
ThemeFileVersion.create_version(@theme_name, relative_path, content, @theme_version.user, @theme_version)
-
rescue => e
-
Rails.logger.error "Error snapshotting file #{relative_path}: #{e.message}"
-
end
-
-
def should_snapshot_file?(file_path)
-
# Only snapshot theme-related files
-
extensions = %w[.liquid .json .css .js .yml .yaml .md .txt]
-
extensions.any? { |ext| file_path.end_with?(ext) }
-
end
-
end
-
class ThemesManager
-
include ActiveModel::Model
-
-
attr_accessor :themes_path
-
-
def initialize
-
@themes_path = Rails.root.join('app', 'themes')
-
end
-
-
# Get all themes from filesystem
-
def scan_themes
-
themes = []
-
-
return themes unless Dir.exist?(@themes_path)
-
-
Dir.glob(File.join(@themes_path, '*')).each do |theme_dir|
-
next unless File.directory?(theme_dir)
-
-
theme_name = File.basename(theme_dir)
-
theme_json_file = File.join(theme_dir, 'config', 'theme.json')
-
-
if File.exist?(theme_json_file)
-
theme_data = JSON.parse(File.read(theme_json_file))
-
# Handle both array and hash formats
-
theme_info = theme_data.is_a?(Array) ? theme_data.first : theme_data
-
-
themes << {
-
name: theme_info['name'] || theme_name,
-
slug: theme_name.parameterize,
-
description: theme_info['description'] || "Theme: #{theme_name}",
-
version: theme_info['version'] || '1.0.0',
-
config: theme_info
-
}
-
else
-
themes << {
-
name: theme_name,
-
slug: theme_name.parameterize,
-
description: "Theme: #{theme_name}",
-
version: '1.0.0',
-
config: {}
-
}
-
end
-
end
-
-
themes
-
end
-
-
# Sync a specific theme from filesystem to database
-
def sync_theme(theme_slug)
-
theme_dir = File.join(@themes_path, theme_slug)
-
-
return false unless Dir.exist?(theme_dir)
-
-
theme_json_file = File.join(theme_dir, 'config', 'theme.json')
-
-
if File.exist?(theme_json_file)
-
theme_data = JSON.parse(File.read(theme_json_file))
-
# Handle both array and hash formats
-
theme_info = theme_data.is_a?(Array) ? theme_data.first : theme_data
-
-
theme_config = {
-
name: theme_info['name'] || theme_slug.titleize,
-
slug: theme_slug,
-
description: theme_info['description'] || "Theme: #{theme_slug.titleize}",
-
version: theme_info['version'] || '1.0.0',
-
config: theme_info
-
}
-
else
-
theme_config = {
-
name: theme_slug.titleize,
-
slug: theme_slug,
-
description: "Theme: #{theme_slug.titleize}",
-
version: '1.0.0',
-
config: {}
-
}
-
end
-
-
# Find or create theme
-
theme = Theme.find_or_create_by(name: theme_config[:name]) do |t|
-
t.slug = theme_config[:slug]
-
t.description = theme_config[:description]
-
t.version = theme_config[:version]
-
t.config = theme_config[:config]
-
t.active = false
-
# Use ActsAsTenant.current_tenant or fallback to first tenant
-
t.tenant = ActsAsTenant.current_tenant || Tenant.first
-
end
-
-
# Update if changed
-
theme.update!(
-
slug: theme_config[:slug],
-
description: theme_config[:description],
-
version: theme_config[:version],
-
config: theme_config[:config]
-
)
-
-
# Sync theme files for this theme
-
sync_theme_files(theme)
-
-
theme
-
end
-
-
# Sync themes from filesystem to database
-
def sync_themes
-
themes = scan_themes
-
synced_count = 0
-
-
themes.each do |theme_data|
-
theme = Theme.find_or_create_by(slug: theme_data[:slug]) do |t|
-
t.name = theme_data[:name]
-
t.description = theme_data[:description]
-
t.version = theme_data[:version]
-
t.config = theme_data[:config]
-
t.active = false
-
# Use ActsAsTenant.current_tenant or fallback to first tenant
-
# Ensure we get a proper Tenant model instance, not OpenStruct
-
current_tenant = ActsAsTenant.current_tenant
-
if current_tenant.is_a?(OpenStruct)
-
t.tenant = Tenant.find(current_tenant.id)
-
else
-
t.tenant = current_tenant || Tenant.first
-
end
-
end
-
-
# Update if changed
-
if theme.changed?
-
theme.update!(theme_data)
-
synced_count += 1
-
end
-
-
# Create initial version if none exists
-
create_initial_version_if_needed(theme)
-
-
# Sync files and detect changes
-
sync_theme_files(theme)
-
end
-
-
synced_count
-
end
-
-
# Create initial theme version if none exists
-
def create_initial_version_if_needed(theme)
-
return if ThemeVersion.for_theme(theme.name).exists?
-
-
# Create initial version
-
theme_version = ThemeVersion.create!(
-
theme_name: theme.name,
-
version: theme.version || '1.0.0',
-
user: User.first,
-
is_live: true,
-
is_preview: false,
-
published_at: Time.current,
-
change_summary: "Initial version from filesystem"
-
)
-
-
# Create theme files for this version
-
create_theme_files_for_version(theme_version)
-
-
# If this is an active theme, also create PublishedThemeVersion
-
if theme.active?
-
theme.ensure_published_version_exists!
-
end
-
end
-
-
# Create theme files for a specific version
-
def create_theme_files_for_version(theme_version)
-
# Use the theme's slug for the directory path, not the name
-
theme = Theme.find_by(name: theme_version.theme_name)
-
theme_slug = theme&.slug || theme_version.theme_name.parameterize
-
theme_path = File.join(@themes_path, theme_slug)
-
return unless Dir.exist?(theme_path)
-
-
files = find_theme_files(theme_path)
-
-
files.each do |file_path|
-
relative_path = file_path.gsub("#{theme_path}/", '')
-
content = File.read(file_path)
-
file_checksum = Digest::SHA256.hexdigest(content)
-
-
# Create theme file for this version - store FULL PATH
-
theme_file = ThemeFile.find_or_create_by(
-
theme_name: theme_version.theme_name,
-
file_path: file_path, # Store full path, not relative
-
theme_version_id: theme_version.id
-
) do |tf|
-
tf.file_type = determine_file_type(relative_path)
-
tf.current_checksum = file_checksum
-
end
-
-
# Update checksum if file exists but checksum differs
-
if theme_file.persisted? && theme_file.current_checksum != file_checksum
-
theme_file.update!(current_checksum: file_checksum)
-
end
-
-
# Create initial file version if none exists
-
create_file_version_if_needed(theme_file, content, file_checksum)
-
end
-
end
-
-
# Create file version if needed (checksum-based)
-
def create_file_version_if_needed(theme_file, content, file_checksum)
-
# Check if we already have a version with this checksum
-
existing_version = theme_file.theme_file_versions.find_by(file_checksum: file_checksum)
-
return existing_version if existing_version
-
-
# Create new version
-
version_number = (theme_file.theme_file_versions.maximum(:version_number) || 0) + 1
-
-
ThemeFileVersion.create!(
-
theme_file: theme_file,
-
content: content,
-
file_size: content.bytesize,
-
file_checksum: file_checksum,
-
user: User.first,
-
change_summary: "Synced from filesystem",
-
version_number: version_number,
-
theme_version_id: theme_file.theme_version_id
-
)
-
end
-
-
# Sync theme files and detect changes
-
def sync_theme_files(theme)
-
theme_version = theme.theme_versions.live.first
-
return unless theme_version
-
-
# Use theme slug for directory path, not name
-
theme_slug = theme.slug || theme.name.parameterize
-
theme_path = File.join(@themes_path, theme_slug)
-
return unless Dir.exist?(theme_path)
-
-
files = find_theme_files(theme_path)
-
files_processed = 0
-
versions_created = 0
-
published_files_updated = 0
-
-
files.each do |file_path|
-
relative_path = file_path.gsub("#{theme_path}/", '')
-
content = File.read(file_path)
-
file_checksum = Digest::SHA256.hexdigest(content)
-
-
# Find or create theme file (use full path for consistency)
-
theme_file = ThemeFile.find_or_create_by(
-
theme_name: theme.name,
-
file_path: file_path, # Store full path
-
theme_version_id: theme_version.id
-
) do |tf|
-
tf.file_type = determine_file_type(relative_path)
-
tf.current_checksum = file_checksum
-
end
-
-
files_processed += 1
-
-
# Check if file has changed (different checksum)
-
if theme_file.current_checksum != file_checksum
-
# Update checksum
-
theme_file.update!(current_checksum: file_checksum)
-
-
# Create new version
-
version = create_file_version_if_needed(theme_file, content, file_checksum)
-
versions_created += 1 if version
-
-
# Update published files if theme is active
-
if theme.active?
-
updated = update_published_files_if_needed(theme, relative_path, content)
-
published_files_updated += 1 if updated
-
end
-
end
-
end
-
-
{ files_processed: files_processed, versions_created: versions_created, published_files_updated: published_files_updated }
-
end
-
-
# Get active theme
-
def active_theme
-
Theme.active.first
-
end
-
-
# Get active theme version for active theme
-
def active_theme_version
-
theme = active_theme
-
return nil unless theme
-
-
ThemeVersion.for_theme(theme.name).live.first
-
end
-
-
# Get file content for active theme or specific theme
-
def get_file(file_path, theme_name = nil)
-
if theme_name
-
# Get file from specific theme
-
theme_version = ThemeVersion.for_theme(theme_name).live.first
-
else
-
# Get file from active theme
-
theme_version = active_theme_version
-
end
-
-
return nil unless theme_version
-
-
# Build full path for lookup - use lowercase theme name for filesystem
-
theme_path = File.join(@themes_path, (theme_name || active_theme.name).downcase)
-
full_path = File.join(theme_path, file_path)
-
-
# Try to find by full path
-
theme_file = theme_version.theme_files.find_by(file_path: full_path)
-
return theme_file.theme_file_versions.latest.first&.content if theme_file
-
-
# If not found, try to find by matching the end of the path (for legacy data)
-
theme_file = theme_version.theme_files.find { |file| file.file_path.end_with?("/#{file_path}") }
-
return nil unless theme_file
-
-
theme_file.theme_file_versions.latest.first&.content
-
end
-
-
# Get file content for builder theme (with overrides)
-
def get_builder_file(builder_theme, file_path)
-
# Check if builder has an override for this file
-
builder_files = builder_theme.settings_data['builder_files'] || {}
-
if builder_files[file_path]
-
return builder_files[file_path]['content']
-
end
-
-
# Fall back to regular theme file
-
get_file(file_path)
-
end
-
-
# Get parsed file content (for JSON files)
-
def get_parsed_file(file_path)
-
content = get_file(file_path)
-
return nil unless content
-
-
if file_path.end_with?('.json')
-
JSON.parse(content)
-
else
-
content
-
end
-
rescue JSON::ParserError
-
nil
-
end
-
-
# Create new file version (for Monaco editor saves)
-
def create_file_version(theme_file, content, user = nil)
-
file_checksum = Digest::SHA256.hexdigest(content)
-
theme_version = theme_file.theme_version
-
-
# Create new version
-
version = ThemeFileVersion.create!(
-
theme_file: theme_file,
-
content: content,
-
file_size: content.bytesize,
-
file_checksum: file_checksum,
-
user: user || User.first,
-
change_summary: "Edited via Monaco Editor",
-
version_number: (theme_file.theme_file_versions.maximum(:version_number) || 0) + 1,
-
theme_version_id: theme_version.id
-
)
-
-
# Update theme file checksum and current version
-
theme_file.update!(
-
current_checksum: file_checksum,
-
current_version: version.version_number
-
)
-
-
version
-
end
-
-
# Get all files for a theme
-
def theme_files(theme_name)
-
theme_version = ThemeVersion.for_theme(theme_name).live.first
-
return [] unless theme_version
-
-
theme_version.theme_files
-
end
-
-
# Get file tree structure
-
def file_tree(theme_name)
-
files = theme_files(theme_name)
-
tree_hash = build_file_tree(files)
-
# Convert hash tree to array format expected by the view
-
convert_tree_to_array(tree_hash)
-
end
-
-
# Check for theme updates
-
def check_for_updates(theme)
-
return false unless theme
-
-
# Compare filesystem version with database version
-
theme_path = File.join(@themes_path, theme.name)
-
theme_json_file = File.join(theme_path, 'config', 'theme.json')
-
-
if File.exist?(theme_json_file)
-
theme_data = JSON.parse(File.read(theme_json_file))
-
theme_info = theme_data.is_a?(Array) ? theme_data.first : theme_data
-
filesystem_version = theme_info['version'] || '1.0.0'
-
database_version = theme.version || '1.0.0'
-
-
filesystem_version != database_version
-
else
-
false
-
end
-
end
-
-
# Update published theme files when files change
-
def update_published_files_if_needed(theme, relative_path, content)
-
published_version = theme.published_version
-
return false unless published_version
-
-
# Find or create published theme file
-
published_file = published_version.published_theme_files.find_or_create_by(
-
file_path: relative_path
-
) do |pf|
-
pf.file_type = determine_file_type(relative_path)
-
pf.content = content
-
pf.checksum = Digest::MD5.hexdigest(content)
-
end
-
-
# Check if content has changed
-
new_checksum = Digest::MD5.hexdigest(content)
-
if published_file.checksum != new_checksum
-
published_file.update!(
-
content: content,
-
checksum: new_checksum
-
)
-
Rails.logger.info "Updated published file: #{relative_path} for theme: #{theme.name}"
-
return true
-
end
-
-
false
-
rescue => e
-
Rails.logger.error "Failed to update published file #{relative_path}: #{e.message}"
-
false
-
end
-
-
private
-
-
# Convert tree hash to array format for the view
-
def convert_tree_to_array(tree_hash, path = '')
-
result = []
-
-
tree_hash.each do |name, content|
-
current_path = path.empty? ? name : "#{path}/#{name}"
-
-
if content.is_a?(Hash) && content[:type] == 'file'
-
# This is a file
-
result << {
-
name: name,
-
path: current_path,
-
type: 'file',
-
editable: content[:editable] || false,
-
extension: File.extname(name),
-
size: content[:size]
-
}
-
elsif content.is_a?(Hash) && content[:type] == 'directory'
-
# This is a directory
-
children = convert_tree_to_array(content[:children] || {}, current_path)
-
result << {
-
name: name,
-
path: current_path,
-
type: 'directory',
-
children: children
-
}
-
elsif content.is_a?(Hash) && content[:children]
-
# This is a directory (has children)
-
children = convert_tree_to_array(content[:children], current_path)
-
result << {
-
name: name,
-
path: current_path,
-
type: 'directory',
-
children: children
-
}
-
else
-
# This might be a nested directory (no explicit type)
-
children = convert_tree_to_array(content, current_path)
-
if children.any? { |child| child[:type] == 'directory' }
-
# Has subdirectories, treat as directory
-
result << {
-
name: name,
-
path: current_path,
-
type: 'directory',
-
children: children
-
}
-
else
-
# All files, add them directly
-
result.concat(children)
-
end
-
end
-
end
-
-
result
-
end
-
-
def find_theme_files(theme_path)
-
files = []
-
-
Dir.glob(File.join(theme_path, '**', '*')).each do |file|
-
next if File.directory?(file)
-
files << file
-
end
-
-
files
-
end
-
-
def determine_file_type(file_path)
-
if file_path.start_with?('templates/')
-
'template'
-
elsif file_path.start_with?('sections/')
-
'section'
-
elsif file_path.start_with?('layout/')
-
'layout'
-
elsif file_path.start_with?('assets/')
-
'asset'
-
elsif file_path.start_with?('config/')
-
'config'
-
else
-
'other'
-
end
-
end
-
-
def build_file_tree(files)
-
tree = {}
-
-
files.each do |file|
-
# Extract the theme directory name from the absolute path
-
# Path format: /path/to/app/themes/theme_name/...
-
path_parts = file.file_path.split('/')
-
theme_index = path_parts.index('themes')
-
-
if theme_index && theme_index + 1 < path_parts.length
-
# Get the relative path after the theme directory
-
theme_dir = path_parts[theme_index + 1]
-
relative_parts = path_parts[(theme_index + 2)..-1]
-
relative_path = relative_parts.join('/')
-
-
path_parts = relative_path.split('/')
-
current = tree
-
-
path_parts.each_with_index do |part, index|
-
if index == path_parts.length - 1
-
# This is a file
-
current[part] = {
-
type: 'file',
-
path: relative_path,
-
theme_file: file,
-
editable: editable_file?(relative_path)
-
}
-
else
-
# This is a directory
-
current[part] ||= {
-
type: 'directory',
-
children: {}
-
}
-
current = current[part][:children]
-
end
-
end
-
end
-
end
-
-
tree
-
end
-
-
def editable_file?(file_path)
-
editable_extensions = %w[.liquid .json .css .js .scss .html .erb]
-
editable_extensions.any? { |ext| file_path.end_with?(ext) }
-
end
-
-
# Additional methods for ThemeEditorController compatibility
-
-
def create_file(file_path, content = '')
-
return false unless valid_file_path?(file_path)
-
-
full_path = File.join(@themes_path, active_theme.name, file_path)
-
-
# Create directory if it doesn't exist
-
FileUtils.mkdir_p(File.dirname(full_path))
-
-
File.write(full_path, content)
-
-
# Create theme file and version
-
theme_version = active_theme_version
-
return false unless theme_version
-
-
theme_file = ThemeFile.create!(
-
theme_name: active_theme.name,
-
file_path: file_path,
-
file_type: determine_file_type(file_path),
-
theme_version: theme_version,
-
current_checksum: Digest::SHA256.hexdigest(content)
-
)
-
-
ThemeFileVersion.create!(
-
theme_file: theme_file,
-
content: content,
-
file_size: content.bytesize,
-
file_checksum: Digest::SHA256.hexdigest(content),
-
user: User.first,
-
change_summary: "File created",
-
version_number: 1,
-
theme_version: theme_version
-
)
-
-
true
-
rescue => e
-
Rails.logger.error "Failed to create file: #{e.message}"
-
false
-
end
-
-
def delete_file(file_path)
-
return false unless valid_file_path?(file_path)
-
-
full_path = File.join(@themes_path, active_theme.name, file_path)
-
-
if File.exist?(full_path)
-
File.delete(full_path)
-
-
# Remove theme file and versions
-
theme_file = ThemeFile.find_by(theme_name: active_theme.name, file_path: file_path)
-
theme_file&.destroy
-
-
true
-
else
-
false
-
end
-
rescue => e
-
Rails.logger.error "Failed to delete file: #{e.message}"
-
false
-
end
-
-
def rename_file(old_path, new_path)
-
return false unless valid_file_path?(old_path) && valid_file_path?(new_path)
-
-
old_full_path = File.join(@themes_path, active_theme.name, old_path)
-
new_full_path = File.join(@themes_path, active_theme.name, new_path)
-
-
if File.exist?(old_full_path)
-
FileUtils.mkdir_p(File.dirname(new_full_path))
-
File.rename(old_full_path, new_full_path)
-
-
# Update theme file path
-
theme_file = ThemeFile.find_by(theme_name: active_theme.name, file_path: old_path)
-
if theme_file
-
theme_file.update!(file_path: new_path)
-
end
-
-
true
-
else
-
false
-
end
-
rescue => e
-
Rails.logger.error "Failed to rename file: #{e.message}"
-
false
-
end
-
-
def search(query)
-
return [] if query.blank?
-
-
results = []
-
theme_path = File.join(@themes_path, active_theme.name)
-
-
Dir.glob(File.join(theme_path, '**', '*')).each do |file_path|
-
next unless File.file?(file_path)
-
next unless editable_file?(File.basename(file_path))
-
-
begin
-
content = File.read(file_path)
-
relative_path = file_path.gsub("#{theme_path}/", '')
-
-
content.each_line.with_index do |line, line_number|
-
if line.include?(query)
-
results << {
-
file: relative_path,
-
line: line_number + 1,
-
content: line.strip,
-
match: line.index(query)
-
}
-
end
-
end
-
rescue => e
-
# Skip files that can't be read
-
end
-
end
-
-
results
-
end
-
-
def file_versions(file_path)
-
theme_file = ThemeFile.find_by(theme_name: active_theme.name, file_path: file_path)
-
return [] unless theme_file
-
-
theme_file.theme_file_versions.order(version_number: :desc)
-
end
-
-
def restore_version(version_id)
-
version = ThemeFileVersion.find(version_id)
-
-
# Write to filesystem
-
theme_path = File.join(@themes_path, active_theme.name, version.theme_file.file_path)
-
File.write(theme_path, version.content)
-
-
# Create new version
-
theme_file = version.theme_file
-
new_version = ThemeFileVersion.create!(
-
theme_file: theme_file,
-
content: version.content,
-
file_size: version.content.bytesize,
-
file_checksum: Digest::SHA256.hexdigest(version.content),
-
user: User.first,
-
change_summary: "Restored from version #{version.version_number}",
-
version_number: (theme_file.theme_file_versions.maximum(:version_number) || 0) + 1,
-
theme_version: theme_file.theme_version
-
)
-
-
# Update theme file checksum
-
theme_file.update!(current_checksum: new_version.file_checksum)
-
-
true
-
rescue => e
-
Rails.logger.error "Failed to restore version: #{e.message}"
-
false
-
end
-
-
def read_file(file_path)
-
get_file(file_path)
-
end
-
-
def errors
-
@errors ||= []
-
end
-
-
private
-
-
def valid_file_path?(file_path)
-
# Prevent path traversal attacks
-
return false if file_path.include?('..')
-
return false if file_path.start_with?('/')
-
-
true
-
end
-
end
-
class ExportWorker
-
include Sidekiq::Worker
-
-
sidekiq_options retry: 3, queue: :default
-
-
def perform(export_job_id)
-
export_job = ExportJob.find(export_job_id)
-
export_job.update(status: 'processing', progress: 0)
-
-
case export_job.export_type
-
when 'wordpress'
-
export_wordpress_xml(export_job)
-
when 'json'
-
export_json(export_job)
-
when 'csv'
-
export_csv(export_job)
-
when 'sql'
-
export_sql(export_job)
-
else
-
raise "Unknown export type: #{export_job.export_type}"
-
end
-
-
export_job.update(
-
status: 'completed',
-
progress: 100
-
)
-
rescue => e
-
Rails.logger.error("Export job #{export_job_id} failed: #{e.message}")
-
Rails.logger.error(e.backtrace.join("\n"))
-
-
export_job.update(status: 'failed')
-
end
-
-
private
-
-
def export_json(export_job)
-
options = export_job.metadata
-
data = {}
-
total_items = 0
-
exported = 0
-
-
if options['include_posts']
-
posts = Post.kept
-
posts = posts.published_status if !options['include_drafts']
-
data['posts'] = posts.map { |p| post_to_json(p) }
-
total_items += posts.count
-
end
-
-
if options['include_pages']
-
pages = Page.kept
-
pages = pages.published_status if !options['include_drafts']
-
data['pages'] = pages.map { |p| page_to_json(p) }
-
total_items += pages.count
-
end
-
-
if options['include_users']
-
data['users'] = User.all.map { |u| user_to_json(u) }
-
total_items += User.count
-
end
-
-
if options['include_settings']
-
data['settings'] = {
-
general: Settings.general,
-
writing: Settings.writing,
-
reading: Settings.reading
-
}
-
end
-
-
export_job.update(total_items: total_items)
-
-
# Write to file
-
file_path = Rails.root.join('tmp', "export_#{export_job.id}.json")
-
File.write(file_path, options['prettify_json'] ? JSON.pretty_generate(data) : data.to_json)
-
-
export_job.update(
-
file_path: file_path.to_s,
-
file_name: "railspress_export_#{Date.today}.json",
-
content_type: 'application/json',
-
exported_items: total_items
-
)
-
end
-
-
def export_wordpress_xml(export_job)
-
# Generate WordPress WXR format
-
builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
-
xml.rss('version' => '2.0',
-
'xmlns:excerpt' => 'http://wordpress.org/export/1.2/excerpt/',
-
'xmlns:content' => 'http://purl.org/rss/1.0/modules/content/',
-
'xmlns:wp' => 'http://wordpress.org/export/1.2/') do
-
xml.channel do
-
xml.title Settings.site_title
-
xml.link request.base_url
-
-
posts = Post.kept
-
posts.each do |post|
-
xml.item do
-
xml.title post.title
-
xml.link "#{request.base_url}/blog/#{post.slug}"
-
xml['content'].encoded { xml.cdata post.content.to_s }
-
xml['wp'].post_name post.slug
-
xml['wp'].status post.published_status? ? 'publish' : 'draft'
-
xml['wp'].post_type 'post'
-
xml['wp'].post_date post.created_at.strftime('%Y-%m-%d %H:%M:%S')
-
end
-
end
-
end
-
end
-
end
-
-
file_path = Rails.root.join('tmp', "export_#{export_job.id}.xml")
-
File.write(file_path, builder.to_xml)
-
-
export_job.update(
-
file_path: file_path.to_s,
-
file_name: "wordpress_export_#{Date.today}.xml",
-
content_type: 'application/xml',
-
exported_items: Post.kept.count
-
)
-
end
-
-
def post_to_json(post)
-
{
-
id: post.id,
-
title: post.title,
-
slug: post.slug,
-
content: post.content.to_s,
-
excerpt: post.excerpt,
-
status: post.status,
-
published_at: post.published_at,
-
author: post.user&.email,
-
categories: post.terms.joins(:taxonomy).where(taxonomies: { slug: 'category' }).pluck(:name),
-
tags: post.terms.joins(:taxonomy).where(taxonomies: { slug: 'tag' }).pluck(:name)
-
}
-
end
-
-
def page_to_json(page)
-
{
-
id: page.id,
-
title: page.title,
-
slug: page.slug,
-
content: page.content.to_s,
-
status: page.status,
-
published_at: page.published_at
-
}
-
end
-
-
def user_to_json(user)
-
{
-
id: user.id,
-
email: user.email,
-
name: user.name,
-
role: user.role,
-
created_at: user.created_at
-
}
-
end
-
end
-
-
-
-
class ImportWorker
-
include Sidekiq::Worker
-
-
sidekiq_options retry: 3, queue: :default
-
-
def perform(import_job_id)
-
import_job = ImportJob.find(import_job_id)
-
import_job.update(status: 'processing', progress: 0)
-
-
case import_job.import_type
-
when 'wordpress'
-
import_wordpress_xml(import_job)
-
when 'json'
-
import_json(import_job)
-
when 'csv_posts'
-
import_csv_posts(import_job)
-
when 'csv_pages'
-
import_csv_pages(import_job)
-
when 'csv_users'
-
import_csv_users(import_job)
-
else
-
raise "Unknown import type: #{import_job.import_type}"
-
end
-
-
import_job.update(
-
status: 'completed',
-
progress: 100,
-
completed_at: Time.current
-
)
-
rescue => e
-
Rails.logger.error("Import job #{import_job_id} failed: #{e.message}")
-
Rails.logger.error(e.backtrace.join("\n"))
-
-
import_job.update(
-
status: 'failed',
-
error_log: "#{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
-
)
-
end
-
-
private
-
-
def import_wordpress_xml(import_job)
-
require 'nokogiri'
-
-
doc = File.open(import_job.file_path) { |f| Nokogiri::XML(f) }
-
items = doc.xpath('//item')
-
-
import_job.update(total_items: items.count)
-
imported = 0
-
failed = 0
-
-
items.each_with_index do |item, index|
-
begin
-
post_type = item.xpath('wp:post_type').text
-
-
case post_type
-
when 'post'
-
create_post_from_wordpress(item, import_job)
-
when 'page'
-
create_page_from_wordpress(item, import_job)
-
end
-
-
imported += 1
-
rescue => e
-
Rails.logger.error("Failed to import item #{index}: #{e.message}")
-
failed += 1
-
end
-
-
# Update progress
-
progress = ((index + 1).to_f / items.count * 100).to_i
-
import_job.update(progress: progress, imported_items: imported, failed_items: failed)
-
end
-
end
-
-
def import_json(import_job)
-
data = JSON.parse(File.read(import_job.file_path))
-
total_items = (data['posts']&.count || 0) + (data['pages']&.count || 0)
-
-
import_job.update(total_items: total_items)
-
imported = 0
-
-
# Import posts
-
data['posts']&.each do |post_data|
-
Post.create!(
-
title: post_data['title'],
-
content: post_data['content'],
-
slug: post_data['slug'],
-
status: post_data['status'] || 'draft',
-
user_id: import_job.user_id
-
)
-
imported += 1
-
import_job.update(progress: (imported.to_f / total_items * 100).to_i, imported_items: imported)
-
end
-
-
# Import pages
-
data['pages']&.each do |page_data|
-
Page.create!(
-
title: page_data['title'],
-
content: page_data['content'],
-
slug: page_data['slug'],
-
status: page_data['status'] || 'draft'
-
)
-
imported += 1
-
import_job.update(progress: (imported.to_f / total_items * 100).to_i, imported_items: imported)
-
end
-
end
-
-
def import_csv_posts(import_job)
-
require 'csv'
-
-
csv_data = CSV.read(import_job.file_path, headers: true)
-
import_job.update(total_items: csv_data.count)
-
-
csv_data.each_with_index do |row, index|
-
Post.create!(
-
title: row['title'],
-
content: row['content'],
-
slug: row['slug'] || row['title'].parameterize,
-
status: row['status'] || 'draft',
-
user_id: import_job.user_id
-
)
-
-
import_job.update(
-
progress: ((index + 1).to_f / csv_data.count * 100).to_i,
-
imported_items: index + 1
-
)
-
end
-
end
-
-
def import_csv_pages(import_job)
-
require 'csv'
-
-
csv_data = CSV.read(import_job.file_path, headers: true)
-
import_job.update(total_items: csv_data.count)
-
-
csv_data.each_with_index do |row, index|
-
Page.create!(
-
title: row['title'],
-
content: row['content'],
-
slug: row['slug'] || row['title'].parameterize,
-
status: row['status'] || 'draft'
-
)
-
-
import_job.update(
-
progress: ((index + 1).to_f / csv_data.count * 100).to_i,
-
imported_items: index + 1
-
)
-
end
-
end
-
-
def import_csv_users(import_job)
-
require 'csv'
-
-
csv_data = CSV.read(import_job.file_path, headers: true)
-
import_job.update(total_items: csv_data.count)
-
-
csv_data.each_with_index do |row, index|
-
User.create!(
-
email: row['email'],
-
name: row['name'],
-
role: row['role'] || 'subscriber',
-
password: SecureRandom.hex(16)
-
)
-
-
import_job.update(
-
progress: ((index + 1).to_f / csv_data.count * 100).to_i,
-
imported_items: index + 1
-
)
-
end
-
end
-
-
def create_post_from_wordpress(item, import_job)
-
Post.create!(
-
title: item.xpath('title').text,
-
content: item.xpath('content:encoded').text,
-
slug: item.xpath('wp:post_name').text,
-
status: item.xpath('wp:status').text == 'publish' ? 'published' : 'draft',
-
published_at: item.xpath('wp:post_date').text,
-
user_id: import_job.user_id
-
)
-
end
-
-
def create_page_from_wordpress(item, import_job)
-
Page.create!(
-
title: item.xpath('title').text,
-
content: item.xpath('content:encoded').text,
-
slug: item.xpath('wp:post_name').text,
-
status: item.xpath('wp:status').text == 'publish' ? 'published' : 'draft',
-
published_at: item.xpath('wp:post_date').text
-
)
-
end
-
end
-
-
-
-
-
-
-
-
-
class PersonalDataErasureWorker
-
include Sidekiq::Worker
-
-
sidekiq_options retry: 1, queue: :default
-
-
def perform(request_id)
-
request = PersonalDataErasureRequest.find(request_id)
-
request.update(status: 'processing')
-
-
user = User.find(request.user_id)
-
-
begin
-
# Create a backup before erasure (for audit purposes)
-
create_erasure_backup(request, user)
-
-
# Anonymize or delete personal data
-
erase_user_data(user)
-
-
# Update request status
-
request.update!(
-
status: 'completed',
-
completed_at: Time.current,
-
metadata: request.metadata.merge(
-
erasure_completed_at: Time.current,
-
erased_data_categories: get_erased_data_categories(user)
-
)
-
)
-
-
# Log the completion
-
Rails.logger.info("Personal data erasure completed for user #{user.email} (ID: #{user.id})")
-
-
rescue => e
-
Rails.logger.error("Personal data erasure failed for request #{request_id}: #{e.message}")
-
request.update!(status: 'failed')
-
raise e
-
end
-
end
-
-
private
-
-
def create_erasure_backup(request, user)
-
# Create a minimal backup for audit purposes
-
backup_data = {
-
erasure_request_id: request.id,
-
user_id: user.id,
-
user_email: user.email,
-
erasure_date: Time.current,
-
reason: request.reason,
-
metadata: request.metadata,
-
data_categories_erased: get_data_categories_to_erase(user)
-
}
-
-
backup_file_path = Rails.root.join('tmp', "erasure_backup_#{request.id}.json")
-
File.write(backup_file_path, JSON.pretty_generate(backup_data))
-
-
# Store backup path in request metadata
-
request.update!(
-
metadata: request.metadata.merge(
-
backup_file_path: backup_file_path.to_s
-
)
-
)
-
end
-
-
def erase_user_data(user)
-
# 1. Anonymize user profile (keep account for system integrity)
-
user.update!(
-
email: "deleted_user_#{user.id}@deleted.local",
-
name: "Deleted User",
-
bio: nil,
-
website: nil,
-
phone: nil,
-
location: nil,
-
# Keep role and created_at for audit purposes
-
# Keep tenant_id for system integrity
-
)
-
-
# 2. Delete user's posts (or anonymize if needed for system integrity)
-
user.posts.each do |post|
-
post.update!(
-
title: "[Deleted Post]",
-
content: "This post has been deleted due to data erasure request.",
-
slug: "deleted-post-#{post.id}"
-
)
-
end
-
-
# 3. Delete user's pages (or anonymize)
-
user.pages.each do |page|
-
page.update!(
-
title: "[Deleted Page]",
-
content: "This page has been deleted due to data erasure request.",
-
slug: "deleted-page-#{page.id}"
-
)
-
end
-
-
# 4. Delete user's media files
-
user.media.each do |medium|
-
# Delete the actual file
-
medium.file.purge if medium.file.attached?
-
# Delete the record
-
medium.destroy!
-
end
-
-
# 5. Anonymize comments by email
-
Comment.where(email: user.email).each do |comment|
-
comment.update!(
-
author_name: "Deleted User",
-
author_email: "deleted@deleted.local",
-
content: "[This comment has been deleted due to data erasure request.]"
-
)
-
end
-
-
# 6. Delete subscriber records
-
Subscriber.where(email: user.email).destroy_all
-
-
# 7. Delete API tokens
-
user.api_tokens.destroy_all
-
-
# 8. Delete meta fields
-
user.meta_fields.destroy_all
-
-
# 9. Delete analytics data (pageviews)
-
Pageview.where(user_id: user.id).destroy_all
-
-
# 10. Delete consent records
-
UserConsent.where(user: user).destroy_all
-
-
# 11. Delete OAuth accounts
-
user.oauth_accounts.destroy_all
-
-
# 12. Delete AI usage records
-
user.ai_usages.destroy_all
-
-
# Note: We don't delete the user record itself to maintain referential integrity
-
# The user account is anonymized but kept for audit purposes
-
end
-
-
def get_data_categories_to_erase(user)
-
categories = []
-
categories << 'profile_data' if user.persisted?
-
categories << 'posts' if user.posts.exists?
-
categories << 'pages' if user.pages.exists?
-
categories << 'media' if user.media.exists?
-
categories << 'comments' if Comment.where(email: user.email).exists?
-
categories << 'subscribers' if Subscriber.where(email: user.email).exists?
-
categories << 'api_tokens' if user.api_tokens.exists?
-
categories << 'meta_fields' if user.meta_fields.exists?
-
categories << 'analytics' if Pageview.where(user_id: user.id).exists?
-
categories << 'consent_records' if UserConsent.where(user: user).exists?
-
categories << 'oauth_accounts' if user.oauth_accounts.exists?
-
categories << 'ai_usage' if user.ai_usages.exists?
-
categories
-
end
-
-
def get_erased_data_categories(user)
-
get_data_categories_to_erase(user)
-
end
-
end
-
class PersonalDataExportWorker
-
include Sidekiq::Worker
-
-
sidekiq_options retry: 2, queue: :default
-
-
def perform(request_id)
-
request = PersonalDataExportRequest.find(request_id)
-
request.update(status: 'processing')
-
-
user = User.find(request.user_id)
-
-
# Compile all personal data
-
personal_data = {
-
request_info: {
-
requested_at: request.created_at,
-
email: request.email,
-
export_date: Time.current
-
},
-
user_profile: {
-
id: user.id,
-
email: user.email,
-
name: user.name,
-
role: user.role,
-
bio: user.bio,
-
website: user.website,
-
created_at: user.created_at,
-
updated_at: user.updated_at
-
},
-
posts: user.posts.map { |p|
-
{
-
title: p.title,
-
slug: p.slug,
-
content: p.content.to_s,
-
status: p.status,
-
published_at: p.published_at,
-
created_at: p.created_at
-
}
-
},
-
comments: Comment.where(email: user.email).map { |c|
-
{
-
content: c.content,
-
author_name: c.author_name,
-
post_title: c.commentable&.title,
-
created_at: c.created_at,
-
ip_address: c.ip_address
-
}
-
},
-
subscribers: Subscriber.where(email: user.email).map { |s|
-
{
-
email: s.email,
-
status: s.status,
-
subscribed_at: s.confirmed_at,
-
lists: s.lists
-
}
-
},
-
pageviews: Pageview.where(user_id: user.id).group(:path).count,
-
metadata: {
-
total_posts: user.posts.count,
-
total_comments: Comment.where(email: user.email).count,
-
total_pageviews: Pageview.where(user_id: user.id).count
-
}
-
}
-
-
# Write to file
-
file_path = Rails.root.join('tmp', "personal_data_#{request.id}.json")
-
File.write(file_path, JSON.pretty_generate(personal_data))
-
-
request.update(
-
status: 'completed',
-
file_path: file_path.to_s,
-
completed_at: Time.current
-
)
-
-
# Send notification email (optional)
-
# PersonalDataMailer.export_ready(request).deliver_later
-
-
rescue => e
-
Rails.logger.error("Personal data export #{request_id} failed: #{e.message}")
-
request.update(status: 'failed')
-
end
-
end
-
-
-
-
-
-
-
-
-
class PostByEmailWorker
-
include Sidekiq::Worker
-
-
sidekiq_options queue: :default, retry: 3
-
-
def perform
-
Rails.logger.info "Starting Post by Email check..."
-
-
result = PostByEmailService.check_mail
-
-
Rails.logger.info "Post by Email check completed: #{result[:new_posts]} new post(s), #{result[:checked]} email(s) checked"
-
rescue => e
-
Rails.logger.error "Post by Email worker failed: #{e.message}"
-
Rails.logger.error e.backtrace.join("\n")
-
raise e # Re-raise to trigger Sidekiq retry
-
end
-
end
-
-
-
-
-
-
-
-
-
class DevelopmentPluginWatcher
-
def self.start_watching
-
return unless Rails.env.development?
-
-
# Create a thread to watch for plugin changes
-
Thread.new do
-
watch_plugin_changes
-
end
-
end
-
-
private
-
-
def self.watch_plugin_changes
-
trigger_file = Rails.root.join('tmp', 'plugin_reload_trigger')
-
-
loop do
-
if File.exist?(trigger_file)
-
begin
-
data = JSON.parse(File.read(trigger_file))
-
plugin_name = data['plugin_name']
-
action = data['action']
-
-
Rails.logger.info "🔄 Detected plugin #{action}: #{plugin_name}"
-
-
# Remove the trigger file
-
File.delete(trigger_file)
-
-
# Trigger a graceful restart
-
trigger_graceful_restart(plugin_name, action)
-
-
rescue => e
-
Rails.logger.error "Error processing plugin reload trigger: #{e.message}"
-
File.delete(trigger_file) if File.exist?(trigger_file)
-
end
-
end
-
-
sleep 1
-
end
-
end
-
-
def self.trigger_graceful_restart(plugin_name, action)
-
Rails.logger.info "🔄 Gracefully restarting server for plugin #{action}: #{plugin_name}"
-
-
# Send a signal to restart the server
-
# This is a development-only feature
-
if defined?(Puma)
-
# Puma restart
-
Process.kill('USR1', Process.pid)
-
elsif defined?(Unicorn)
-
# Unicorn restart
-
Process.kill('USR2', Process.pid)
-
else
-
# Fallback: create a restart file
-
restart_file = Rails.root.join('tmp', 'restart.txt')
-
FileUtils.touch(restart_file)
-
Rails.logger.info "📝 Created restart file. Please restart the server manually."
-
end
-
end
-
end
-
# Plugin Generator for RailsPress
-
#
-
# Usage:
-
# rails generate plugin MyPlugin
-
# rails generate plugin MyPlugin --with-models
-
# rails generate plugin MyPlugin --with-admin-ui
-
# rails generate plugin MyPlugin --full
-
#
-
# This will create:
-
# - Plugin class in lib/plugins/my_plugin/my_plugin.rb
-
# - Model in app/models/ (if --with-models)
-
# - Admin controller in app/controllers/admin/my_plugin/
-
# - Frontend controller in app/controllers/plugins/my_plugin/
-
# - Admin views in app/views/admin/my_plugin/
-
# - Frontend views in app/views/plugins/my_plugin/
-
# - Migration files
-
# - Asset files
-
-
class PluginGenerator < Rails::Generators::NamedBase
-
source_root File.expand_path('templates', __dir__)
-
-
class_option :with_models, type: :boolean, default: false,
-
desc: 'Generate ActiveRecord models'
-
class_option :with_admin_ui, type: :boolean, default: true,
-
desc: 'Generate admin UI (controllers and views)'
-
class_option :with_frontend, type: :boolean, default: false,
-
desc: 'Generate frontend UI (controllers and views)'
-
class_option :full, type: :boolean, default: false,
-
desc: 'Generate everything (models, admin, frontend, assets)'
-
class_option :author, type: :string, default: 'RailsPress',
-
desc: 'Plugin author name'
-
class_option :description, type: :string,
-
desc: 'Plugin description'
-
-
def create_plugin_structure
-
@plugin_name = name
-
@plugin_class = name.camelize
-
@plugin_underscore = name.underscore
-
@plugin_identifier = @plugin_underscore
-
@author = options[:author]
-
@description = options[:description] || "A RailsPress plugin"
-
@full = options[:full]
-
-
create_plugin_class
-
create_models if options[:with_models] || @full
-
create_controllers if options[:with_admin_ui] || options[:with_frontend] || @full
-
create_views if options[:with_admin_ui] || options[:with_frontend] || @full
-
create_assets if @full
-
create_jobs if @full
-
create_tests if @full
-
create_readme
-
create_database_record
-
-
say "\n✓ Plugin '#{@plugin_name}' created successfully!", :green
-
say "\nNext steps:", :yellow
-
say " 1. Run: rails db:migrate"
-
say " 2. Activate plugin in admin panel at /admin/plugins"
-
say " 3. Configure plugin settings"
-
say " 4. Restart Rails server\n"
-
end
-
-
private
-
-
def create_plugin_class
-
template_file = 'plugin_template.rb'
-
destination = "lib/plugins/#{@plugin_underscore}/#{@plugin_underscore}.rb"
-
-
content = <<~RUBY
-
# #{@plugin_class} - #{@description}
-
#
-
# A professional RailsPress plugin with full MVC support
-
-
class #{@plugin_class} < Railspress::PluginBase
-
plugin_name '#{@plugin_name}'
-
plugin_version '1.0.0'
-
plugin_description '#{@description}'
-
plugin_author '#{@author}'
-
plugin_url 'https://example.com/plugins/#{@plugin_underscore}'
-
plugin_license 'GPL-2.0'
-
-
def setup
-
# ========================================
-
# SETTINGS
-
# ========================================
-
-
define_setting :enabled,
-
type: 'boolean',
-
label: 'Enable Plugin',
-
description: 'Enable or disable this plugin',
-
default: true
-
-
define_setting :api_key,
-
type: 'string',
-
label: 'API Key',
-
description: 'Your API key for external services',
-
placeholder: 'sk-...',
-
required: false
-
-
# ========================================
-
# ADMIN PAGES
-
# ========================================
-
-
register_admin_page(
-
slug: 'dashboard',
-
title: '#{@plugin_name} Dashboard',
-
menu_title: 'Dashboard',
-
icon: 'chart-bar',
-
callback: :render_dashboard
-
)
-
-
register_admin_page(
-
slug: 'settings',
-
title: '#{@plugin_name} Settings',
-
menu_title: 'Settings',
-
icon: 'cog'
-
)
-
-
# ========================================
-
# ROUTES
-
# ========================================
-
-
# Admin routes (scoped under /admin/#{@plugin_underscore})
-
register_admin_routes do
-
resources :items do
-
member do
-
post :duplicate
-
end
-
collection do
-
get :export
-
post :import
-
end
-
end
-
-
get 'dashboard', to: 'dashboard#index'
-
get 'settings', to: 'settings#index'
-
patch 'settings', to: 'settings#update'
-
end
-
-
# Frontend routes (scoped under /plugins/#{@plugin_underscore})
-
register_frontend_routes do
-
resources :items, only: [:index, :show]
-
get 'search', to: 'items#search'
-
end
-
-
# ========================================
-
# ASSETS
-
# ========================================
-
-
register_stylesheet('#{@plugin_underscore}.css', admin_only: true)
-
register_javascript('#{@plugin_underscore}.js', admin_only: true)
-
register_stylesheet('#{@plugin_underscore}_frontend.css', frontend_only: true)
-
register_javascript('#{@plugin_underscore}_frontend.js', frontend_only: true)
-
-
# ========================================
-
# WEBHOOKS & EVENTS
-
# ========================================
-
-
register_webhook('item.created', ENV['WEBHOOK_URL'], {
-
method: 'POST',
-
headers: { 'Content-Type' => 'application/json' }
-
})
-
-
on('user.registered') do |data|
-
log("New user registered: \#{data[:user][:email]}", :info)
-
end
-
-
# ========================================
-
# BACKGROUND JOBS
-
# ========================================
-
-
schedule_task('daily_cleanup', '0 2 * * *') do
-
log("Running daily cleanup for #{@plugin_name}", :info)
-
# Cleanup logic here
-
end
-
-
# ========================================
-
# HOOKS & FILTERS
-
# ========================================
-
-
add_action('init', :initialize_plugin)
-
add_filter('post_content', :modify_content)
-
end
-
-
# ========================================
-
# ACTIVATION / DEACTIVATION
-
# ========================================
-
-
def activate
-
super
-
log("Activating #{@plugin_name}", :info)
-
-
# Create database tables
-
create_migrations
-
-
# Set default settings
-
set_setting(:enabled, true)
-
-
log("#{@plugin_name} activated successfully", :success)
-
end
-
-
def deactivate
-
super
-
log("Deactivating #{@plugin_name}", :info)
-
end
-
-
def uninstall
-
super
-
log("Uninstalling #{@plugin_name}", :info)
-
-
# Remove database tables
-
drop_tables
-
-
log("#{@plugin_name} uninstalled", :success)
-
end
-
-
# ========================================
-
# CUSTOM METHODS
-
# ========================================
-
-
def initialize_plugin
-
log("Initializing #{@plugin_name}", :debug)
-
end
-
-
def modify_content(content)
-
# Modify post content
-
content
-
end
-
-
def render_dashboard
-
# Custom dashboard rendering logic
-
items_count = #{@plugin_class}Item.count rescue 0
-
-
<<~HTML
-
<div class="space-y-6">
-
<h1 class="text-2xl font-bold text-white">#{@plugin_name} Dashboard</h1>
-
-
<div class="grid grid-cols-3 gap-4">
-
<div class="bg-[#111111] rounded-lg p-6">
-
<div class="text-gray-400 text-sm mb-2">Total Items</div>
-
<div class="text-3xl font-bold text-white">\#{items_count}</div>
-
</div>
-
</div>
-
-
<div class="bg-[#111111] rounded-lg p-6">
-
<p class="text-gray-300">Welcome to #{@plugin_name}!</p>
-
<p class="text-gray-400 mt-2">Configure your settings and start using the plugin.</p>
-
</div>
-
</div>
-
HTML
-
end
-
-
private
-
-
def create_migrations
-
# Create plugin tables
-
create_plugin_migration('create_#{@plugin_underscore}_items') do |t|
-
t.string :title, null: false
-
t.text :description
-
t.json :metadata, default: {}
-
t.boolean :active, default: true
-
t.integer :tenant_id
-
t.timestamps
-
-
t.index :title
-
t.index :active
-
t.index :tenant_id
-
end
-
end
-
-
def drop_tables
-
# Remove plugin tables
-
ActiveRecord::Base.connection.drop_table(:#{@plugin_underscore}_items) if table_exists?(:#{@plugin_underscore}_items)
-
end
-
-
def table_exists?(table_name)
-
ActiveRecord::Base.connection.table_exists?(table_name)
-
end
-
end
-
-
# Register plugin
-
Railspress::PluginSystem.register_plugin('#{@plugin_identifier}', #{@plugin_class}.new)
-
RUBY
-
-
create_file destination, content
-
end
-
-
def create_models
-
return unless options[:with_models] || @full
-
-
model_name = "#{@plugin_class}Item"
-
model_file = "app/models/#{@plugin_underscore}_item.rb"
-
-
content = <<~RUBY
-
# #{model_name} Model
-
# Belongs to #{@plugin_class} plugin
-
-
class #{model_name} < ApplicationRecord
-
# Multi-tenancy
-
acts_as_tenant(:tenant, optional: true)
-
-
# Associations
-
belongs_to :user, optional: true
-
-
# Validations
-
validates :title, presence: true
-
-
# Scopes
-
scope :active, -> { where(active: true) }
-
scope :recent, -> { order(created_at: :desc) }
-
-
# Callbacks
-
before_save :ensure_defaults
-
after_create :log_creation
-
-
# Instance methods
-
def to_liquid
-
{
-
'id' => id,
-
'title' => title,
-
'description' => description,
-
'active' => active,
-
'created_at' => created_at,
-
'updated_at' => updated_at
-
}
-
end
-
-
private
-
-
def ensure_defaults
-
self.metadata ||= {}
-
end
-
-
def log_creation
-
Rails.logger.info "Created #{model_name}: \#{title} (ID: \#{id})"
-
end
-
end
-
RUBY
-
-
create_file model_file, content
-
end
-
-
def create_controllers
-
create_admin_controller if options[:with_admin_ui] || @full
-
create_frontend_controller if options[:with_frontend] || @full
-
end
-
-
def create_admin_controller
-
controller_file = "app/controllers/admin/#{@plugin_underscore}/items_controller.rb"
-
-
content = <<~RUBY
-
# Admin Controller for #{@plugin_class}
-
# Handles CRUD operations for #{@plugin_class} items
-
-
class Admin::#{@plugin_class}::ItemsController < Admin::BaseController
-
before_action :set_item, only: [:show, :edit, :update, :destroy, :duplicate]
-
-
# GET /admin/#{@plugin_underscore}/items
-
def index
-
@items = #{@plugin_class}Item.accessible_by(current_tenant)
-
.recent
-
.page(params[:page])
-
end
-
-
# GET /admin/#{@plugin_underscore}/items/:id
-
def show
-
end
-
-
# GET /admin/#{@plugin_underscore}/items/new
-
def new
-
@item = #{@plugin_class}Item.new
-
end
-
-
# POST /admin/#{@plugin_underscore}/items
-
def create
-
@item = #{@plugin_class}Item.new(item_params)
-
@item.tenant = current_tenant
-
@item.user = current_user
-
-
if @item.save
-
redirect_to admin_#{@plugin_underscore}_item_path(@item),
-
notice: 'Item was successfully created.'
-
else
-
render :new, status: :unprocessable_entity
-
end
-
end
-
-
# GET /admin/#{@plugin_underscore}/items/:id/edit
-
def edit
-
end
-
-
# PATCH/PUT /admin/#{@plugin_underscore}/items/:id
-
def update
-
if @item.update(item_params)
-
redirect_to admin_#{@plugin_underscore}_item_path(@item),
-
notice: 'Item was successfully updated.'
-
else
-
render :edit, status: :unprocessable_entity
-
end
-
end
-
-
# DELETE /admin/#{@plugin_underscore}/items/:id
-
def destroy
-
@item.destroy
-
redirect_to admin_#{@plugin_underscore}_items_path,
-
notice: 'Item was successfully deleted.'
-
end
-
-
# POST /admin/#{@plugin_underscore}/items/:id/duplicate
-
def duplicate
-
new_item = @item.dup
-
new_item.title = "\#{@item.title} (Copy)"
-
-
if new_item.save
-
redirect_to admin_#{@plugin_underscore}_item_path(new_item),
-
notice: 'Item was successfully duplicated.'
-
else
-
redirect_to admin_#{@plugin_underscore}_items_path,
-
alert: 'Failed to duplicate item.'
-
end
-
end
-
-
# GET /admin/#{@plugin_underscore}/items/export
-
def export
-
@items = #{@plugin_class}Item.accessible_by(current_tenant).all
-
-
respond_to do |format|
-
format.csv do
-
send_data generate_csv(@items),
-
filename: "#{@plugin_underscore}_items_\#{Date.today}.csv"
-
end
-
format.json do
-
render json: @items
-
end
-
end
-
end
-
-
private
-
-
def set_item
-
@item = #{@plugin_class}Item.accessible_by(current_tenant).find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to admin_#{@plugin_underscore}_items_path,
-
alert: 'Item not found.'
-
end
-
-
def item_params
-
params.require(:#{@plugin_underscore}_item).permit(
-
:title, :description, :active, metadata: {}
-
)
-
end
-
-
def generate_csv(items)
-
CSV.generate(headers: true) do |csv|
-
csv << ['ID', 'Title', 'Description', 'Active', 'Created At']
-
-
items.each do |item|
-
csv << [item.id, item.title, item.description, item.active, item.created_at]
-
end
-
end
-
end
-
end
-
RUBY
-
-
create_file controller_file, content
-
end
-
-
def create_frontend_controller
-
controller_file = "app/controllers/plugins/#{@plugin_underscore}/items_controller.rb"
-
-
content = <<~RUBY
-
# Frontend Controller for #{@plugin_class}
-
# Handles public-facing item display
-
-
class Plugins::#{@plugin_class}::ItemsController < ApplicationController
-
before_action :set_item, only: [:show]
-
-
# GET /plugins/#{@plugin_underscore}/items
-
def index
-
@items = #{@plugin_class}Item.active
-
.accessible_by(current_tenant)
-
.recent
-
.page(params[:page])
-
end
-
-
# GET /plugins/#{@plugin_underscore}/items/:id
-
def show
-
end
-
-
# GET /plugins/#{@plugin_underscore}/search
-
def search
-
@query = params[:q]
-
@items = #{@plugin_class}Item.active
-
.accessible_by(current_tenant)
-
.where('title LIKE ? OR description LIKE ?', "%\#{@query}%", "%\#{@query}%")
-
.recent
-
.page(params[:page])
-
-
render :index
-
end
-
-
private
-
-
def set_item
-
@item = #{@plugin_class}Item.active
-
.accessible_by(current_tenant)
-
.find(params[:id])
-
rescue ActiveRecord::RecordNotFound
-
redirect_to plugins_#{@plugin_underscore}_items_path,
-
alert: 'Item not found.'
-
end
-
end
-
RUBY
-
-
create_file controller_file, content
-
end
-
-
def create_views
-
create_admin_views if options[:with_admin_ui] || @full
-
create_frontend_views if options[:with_frontend] || @full
-
end
-
-
def create_admin_views
-
# Index view
-
create_file "app/views/admin/#{@plugin_underscore}/items/index.html.erb", <<~ERB
-
<div class="space-y-6">
-
<div class="flex items-center justify-between">
-
<h1 class="text-2xl font-bold text-white">#{@plugin_name} Items</h1>
-
<%= link_to new_admin_#{@plugin_underscore}_item_path,
-
class: "btn-primary flex items-center gap-2" do %>
-
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
-
</svg>
-
New Item
-
<% end %>
-
</div>
-
-
<div class="bg-[#111111] rounded-lg overflow-hidden">
-
<table class="w-full">
-
<thead class="bg-[#0a0a0a] border-b border-[#2a2a2a]">
-
<tr>
-
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">Title</th>
-
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">Status</th>
-
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase">Created</th>
-
<th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase">Actions</th>
-
</tr>
-
</thead>
-
<tbody class="divide-y divide-[#2a2a2a]">
-
<% @items.each do |item| %>
-
<tr class="hover:bg-[#1a1a1a]">
-
<td class="px-6 py-4">
-
<%= link_to item.title, admin_#{@plugin_underscore}_item_path(item),
-
class: "text-white hover:text-blue-400" %>
-
</td>
-
<td class="px-6 py-4">
-
<% if item.active? %>
-
<span class="px-2 py-1 text-xs rounded bg-green-900/20 text-green-400">Active</span>
-
<% else %>
-
<span class="px-2 py-1 text-xs rounded bg-gray-700 text-gray-400">Inactive</span>
-
<% end %>
-
</td>
-
<td class="px-6 py-4 text-gray-300">
-
<%= time_ago_in_words(item.created_at) %> ago
-
</td>
-
<td class="px-6 py-4 text-right">
-
<%= link_to 'Edit', edit_admin_#{@plugin_underscore}_item_path(item),
-
class: "text-blue-400 hover:text-blue-300 mr-3" %>
-
<%= link_to 'Delete', admin_#{@plugin_underscore}_item_path(item),
-
data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' },
-
class: "text-red-400 hover:text-red-300" %>
-
</td>
-
</tr>
-
<% end %>
-
</tbody>
-
</table>
-
</div>
-
-
<%= paginate @items %>
-
</div>
-
ERB
-
-
# New/Edit form
-
create_file "app/views/admin/#{@plugin_underscore}/items/_form.html.erb", <<~ERB
-
<%= form_with model: [:admin, :#{@plugin_underscore}, @item], local: true, class: "space-y-6" do |f| %>
-
<% if @item.errors.any? %>
-
<div class="bg-red-900/20 border border-red-500/50 text-red-400 px-4 py-3 rounded-lg">
-
<ul class="list-disc list-inside">
-
<% @item.errors.full_messages.each do |message| %>
-
<li><%= message %></li>
-
<% end %>
-
</ul>
-
</div>
-
<% end %>
-
-
<div>
-
<%= f.label :title, class: "block text-sm font-medium text-gray-300 mb-2" %>
-
<%= f.text_field :title,
-
class: "w-full px-4 py-2 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg text-white focus:border-blue-500",
-
placeholder: "Enter title",
-
required: true %>
-
</div>
-
-
<div>
-
<%= f.label :description, class: "block text-sm font-medium text-gray-300 mb-2" %>
-
<%= f.text_area :description,
-
rows: 4,
-
class: "w-full px-4 py-2 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg text-white focus:border-blue-500",
-
placeholder: "Enter description" %>
-
</div>
-
-
<div class="flex items-center">
-
<%= f.check_box :active, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-600 rounded" %>
-
<%= f.label :active, "Active", class: "ml-2 text-sm text-gray-300" %>
-
</div>
-
-
<div class="flex gap-3">
-
<%= f.submit "Save", class: "btn-primary" %>
-
<%= link_to "Cancel", admin_#{@plugin_underscore}_items_path, class: "btn-secondary" %>
-
</div>
-
<% end %>
-
ERB
-
-
create_file "app/views/admin/#{@plugin_underscore}/items/new.html.erb", <<~ERB
-
<div class="max-w-3xl mx-auto">
-
<h1 class="text-2xl font-bold text-white mb-6">New Item</h1>
-
<%= render 'form' %>
-
</div>
-
ERB
-
-
create_file "app/views/admin/#{@plugin_underscore}/items/edit.html.erb", <<~ERB
-
<div class="max-w-3xl mx-auto">
-
<h1 class="text-2xl font-bold text-white mb-6">Edit Item</h1>
-
<%= render 'form' %>
-
</div>
-
ERB
-
end
-
-
def create_frontend_views
-
create_file "app/views/plugins/#{@plugin_underscore}/items/index.html.erb", <<~ERB
-
<div class="max-w-6xl mx-auto py-8 px-4">
-
<h1 class="text-4xl font-bold mb-8">#{@plugin_name} Items</h1>
-
-
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
-
<% @items.each do |item| %>
-
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
-
<div class="p-6">
-
<h2 class="text-2xl font-bold mb-2">
-
<%= link_to item.title, plugins_#{@plugin_underscore}_item_path(item),
-
class: "text-gray-900 hover:text-blue-600" %>
-
</h2>
-
-
<% if item.description.present? %>
-
<p class="text-gray-600 mb-4">
-
<%= truncate(item.description, length: 150) %>
-
</p>
-
<% end %>
-
-
<div class="flex items-center justify-between">
-
<span class="text-sm text-gray-500">
-
<%= time_ago_in_words(item.created_at) %> ago
-
</span>
-
-
<%= link_to 'View', plugins_#{@plugin_underscore}_item_path(item),
-
class: "text-blue-600 hover:text-blue-700 font-medium" %>
-
</div>
-
</div>
-
</div>
-
<% end %>
-
</div>
-
-
<div class="mt-8">
-
<%= paginate @items %>
-
</div>
-
</div>
-
ERB
-
-
create_file "app/views/plugins/#{@plugin_underscore}/items/show.html.erb", <<~ERB
-
<div class="max-w-4xl mx-auto py-8 px-4">
-
<div class="bg-white rounded-lg shadow-lg p-8">
-
<h1 class="text-4xl font-bold mb-4"><%= @item.title %></h1>
-
-
<div class="text-sm text-gray-500 mb-6">
-
<%= @item.created_at.strftime('%B %d, %Y') %>
-
</div>
-
-
<% if @item.description.present? %>
-
<div class="prose max-w-none">
-
<%= simple_format(@item.description) %>
-
</div>
-
<% end %>
-
-
<div class="mt-8 pt-6 border-t">
-
<%= link_to '← Back to Items', plugins_#{@plugin_underscore}_items_path,
-
class: "text-blue-600 hover:text-blue-700" %>
-
</div>
-
</div>
-
</div>
-
ERB
-
end
-
-
def create_assets
-
# Create asset directories and files
-
empty_directory "lib/plugins/#{@plugin_underscore}/assets/stylesheets"
-
empty_directory "lib/plugins/#{@plugin_underscore}/assets/javascripts"
-
empty_directory "lib/plugins/#{@plugin_underscore}/assets/images"
-
-
create_file "lib/plugins/#{@plugin_underscore}/assets/stylesheets/#{@plugin_underscore}.css", <<~CSS
-
/* Admin styles for #{@plugin_name} */
-
-
.#{@plugin_underscore}-container {
-
padding: 1rem;
-
}
-
-
.#{@plugin_underscore}-card {
-
background: #111111;
-
border-radius: 0.5rem;
-
padding: 1.5rem;
-
}
-
CSS
-
-
create_file "lib/plugins/#{@plugin_underscore}/assets/javascripts/#{@plugin_underscore}.js", <<~JS
-
// Admin JavaScript for #{@plugin_name}
-
-
document.addEventListener('turbo:load', function() {
-
console.log('#{@plugin_name} loaded');
-
-
// Initialize plugin functionality
-
init#{@plugin_class}();
-
});
-
-
function init#{@plugin_class}() {
-
// Plugin initialization code
-
}
-
JS
-
-
create_file "lib/plugins/#{@plugin_underscore}/assets/stylesheets/#{@plugin_underscore}_frontend.css", <<~CSS
-
/* Frontend styles for #{@plugin_name} */
-
-
.#{@plugin_underscore}-item {
-
margin-bottom: 1rem;
-
}
-
CSS
-
-
create_file "lib/plugins/#{@plugin_underscore}/assets/javascripts/#{@plugin_underscore}_frontend.js", <<~JS
-
// Frontend JavaScript for #{@plugin_name}
-
-
document.addEventListener('DOMContentLoaded', function() {
-
console.log('#{@plugin_name} frontend loaded');
-
});
-
JS
-
end
-
-
def create_jobs
-
create_file "app/jobs/#{@plugin_underscore}_job.rb", <<~RUBY
-
# Background job for #{@plugin_name}
-
-
class #{@plugin_class}Job < ApplicationJob
-
queue_as :default
-
-
def perform(*args)
-
# Job logic here
-
Rails.logger.info "Executing #{@plugin_class}Job"
-
end
-
end
-
RUBY
-
end
-
-
def create_tests
-
# Model tests
-
create_file "test/models/#{@plugin_underscore}_item_test.rb", <<~RUBY
-
require 'test_helper'
-
-
class #{@plugin_class}ItemTest < ActiveSupport::TestCase
-
test "should create item" do
-
item = #{@plugin_class}Item.new(title: 'Test Item')
-
assert item.save
-
end
-
-
test "should require title" do
-
item = #{@plugin_class}Item.new
-
assert_not item.save
-
assert_includes item.errors[:title], "can't be blank"
-
end
-
end
-
RUBY
-
-
# Controller tests
-
create_file "test/controllers/admin/#{@plugin_underscore}/items_controller_test.rb", <<~RUBY
-
require 'test_helper'
-
-
class Admin::#{@plugin_class}::ItemsControllerTest < ActionDispatch::IntegrationTest
-
setup do
-
@admin = users(:admin)
-
sign_in @admin
-
end
-
-
test "should get index" do
-
get admin_#{@plugin_underscore}_items_url
-
assert_response :success
-
end
-
-
test "should create item" do
-
assert_difference('#{@plugin_class}Item.count') do
-
post admin_#{@plugin_underscore}_items_url, params: {
-
#{@plugin_underscore}_item: { title: 'Test Item' }
-
}
-
end
-
-
assert_redirected_to admin_#{@plugin_underscore}_item_path(#{@plugin_class}Item.last)
-
end
-
end
-
RUBY
-
end
-
-
def create_readme
-
create_file "lib/plugins/#{@plugin_underscore}/README.md", <<~MD
-
# #{@plugin_name}
-
-
#{@description}
-
-
## Installation
-
-
1. The plugin is installed in `lib/plugins/#{@plugin_underscore}/`
-
2. Run migrations: `rails db:migrate`
-
3. Activate the plugin in admin panel at `/admin/plugins`
-
4. Configure settings at `/admin/plugins/#{@plugin_underscore}/settings`
-
-
## Features
-
-
- Full CRUD operations for items
-
- Admin and frontend interfaces
-
- Multi-tenant support
-
- Background job processing
-
- Webhook integration
-
- Event listeners
-
-
## Configuration
-
-
Configure plugin settings in the admin panel:
-
-
- **Enable Plugin**: Turn the plugin on/off
-
- **API Key**: Configure external service integration
-
-
## Usage
-
-
### Admin Interface
-
-
Access admin features at `/admin/#{@plugin_underscore}`
-
-
### Frontend Interface
-
-
Access public features at `/plugins/#{@plugin_underscore}/items`
-
-
## Development
-
-
### File Structure
-
-
```
-
lib/plugins/#{@plugin_underscore}/
-
├── #{@plugin_underscore}.rb # Main plugin class
-
├── assets/ # Plugin assets
-
├── README.md # This file
-
-
app/
-
├── controllers/
-
│ ├── admin/#{@plugin_underscore}/
-
│ └── plugins/#{@plugin_underscore}/
-
├── views/
-
│ ├── admin/#{@plugin_underscore}/
-
│ └── plugins/#{@plugin_underscore}/
-
├── models/
-
│ └── #{@plugin_underscore}_item.rb
-
└── jobs/
-
└── #{@plugin_underscore}_job.rb
-
```
-
-
### Testing
-
-
Run tests:
-
-
```bash
-
rails test test/models/#{@plugin_underscore}_item_test.rb
-
rails test test/controllers/admin/#{@plugin_underscore}/
-
```
-
-
## API
-
-
### Admin Routes
-
-
- `GET /admin/#{@plugin_underscore}/items` - List items
-
- `POST /admin/#{@plugin_underscore}/items` - Create item
-
- `GET /admin/#{@plugin_underscore}/items/:id` - Show item
-
- `PATCH /admin/#{@plugin_underscore}/items/:id` - Update item
-
- `DELETE /admin/#{@plugin_underscore}/items/:id` - Delete item
-
-
### Frontend Routes
-
-
- `GET /plugins/#{@plugin_underscore}/items` - List items
-
- `GET /plugins/#{@plugin_underscore}/items/:id` - Show item
-
-
## License
-
-
GPL-2.0
-
-
## Author
-
-
#{@author}
-
MD
-
end
-
-
def create_database_record
-
say "\n Creating database record for plugin...", :yellow
-
-
# This will be run after generation
-
# The actual database record should be created manually or via rake task
-
end
-
end
-
-
-
-
-
-
# [Plugin Name] Plugin for RailsPress
-
# [Brief description of what this plugin does]
-
#
-
# Features:
-
# - Feature 1
-
# - Feature 2
-
# - Feature 3
-
#
-
# Settings:
-
# - setting_name (type): Description
-
#
-
# Hooks Registered:
-
# - action_name: Description
-
# - filter_name: Description
-
-
class PluginTemplate < Railspress::PluginBase
-
# Plugin Metadata (required)
-
plugin_name 'Plugin Template'
-
plugin_version '1.0.0'
-
plugin_description 'A template for creating RailsPress plugins'
-
plugin_author 'Your Name'
-
plugin_url 'https://github.com/yourname/plugin-name' # optional
-
plugin_license 'MIT' # optional
-
-
# Plugin configuration (optional)
-
def self.default_settings
-
{
-
'enabled' => true,
-
'setting_1' => 'default_value',
-
'setting_2' => 10
-
}
-
end
-
-
# Activation hook (required)
-
def activate
-
super # Always call super first
-
-
Rails.logger.info "#{plugin_name} v#{plugin_version} activated"
-
-
# Initialize plugin
-
register_hooks
-
register_filters
-
register_shortcodes if respond_to?(:register_shortcodes, true)
-
inject_helpers if respond_to?(:inject_helpers, true)
-
-
# Run one-time setup tasks
-
perform_activation_tasks
-
end
-
-
# Deactivation hook (required)
-
def deactivate
-
super # Always call super first
-
-
Rails.logger.info "#{plugin_name} deactivated"
-
-
# Cleanup
-
cleanup_hooks
-
cleanup_shortcodes if respond_to?(:cleanup_shortcodes, true)
-
end
-
-
private
-
-
# Register action hooks
-
def register_hooks
-
# Examples:
-
# add_action('post_created', :on_post_created)
-
# add_action('page_published', :on_page_published)
-
# add_action('comment_approved', :on_comment_approved)
-
end
-
-
# Register filters
-
def register_filters
-
# Examples:
-
# add_filter('post_content', :modify_post_content)
-
# add_filter('page_title', :modify_page_title)
-
end
-
-
# Register shortcodes (optional)
-
def register_shortcodes
-
# Example:
-
# register_shortcode('my_shortcode') do |attrs, content|
-
# "<div>#{content}</div>"
-
# end
-
end
-
-
# Inject helper methods (optional)
-
def inject_helpers
-
# Example:
-
# ApplicationController.helper(PluginTemplateHelper)
-
end
-
-
# Perform one-time activation tasks
-
def perform_activation_tasks
-
# Examples:
-
# - Create database records
-
# - Generate files
-
# - Set default settings
-
end
-
-
# Cleanup on deactivation
-
def cleanup_hooks
-
# Remove registered hooks/filters
-
# This is usually handled by PluginBase
-
end
-
-
# Hook callback examples
-
def on_post_created(post_id)
-
post = Post.find_by(id: post_id)
-
return unless post
-
-
Rails.logger.info "New post created: #{post.title}"
-
# Add your logic here
-
end
-
-
def on_page_published(page_id)
-
page = Page.find_by(id: page_id)
-
return unless page
-
-
Rails.logger.info "Page published: #{page.title}"
-
# Add your logic here
-
end
-
-
# Filter callback examples
-
def modify_post_content(content)
-
# Modify and return content
-
content
-
end
-
-
def modify_page_title(title)
-
# Modify and return title
-
title
-
end
-
-
# Public API methods (can be called from anywhere)
-
def self.do_something(param)
-
# Your public plugin method
-
end
-
-
# Settings helpers (inherited from PluginBase)
-
# get_setting(key, default)
-
# set_setting(key, value)
-
# setting_enabled?(key)
-
end
-
-
# Helper module (optional)
-
module PluginTemplateHelper
-
def plugin_template_method
-
# Your helper method
-
end
-
end
-
-
# Initialize the plugin
-
PluginTemplate.new
-
-
-
-
-
-
-
-
-
class AdvancedShortcodes < Railspress::PluginBase
-
plugin_name 'Advanced Shortcodes'
-
plugin_version '2.0.0'
-
plugin_description 'Extended shortcode library with schema-based settings'
-
plugin_author 'RailsPress Team'
-
-
# Define comprehensive settings schema showcasing all field types
-
settings_schema do
-
section 'Appearance', description: 'Control the visual appearance of shortcodes' do
-
color 'button_color', 'Default Button Color',
-
description: 'Default color for [button] shortcodes',
-
default: '#3B82F6'
-
-
color 'accent_color', 'Accent Color',
-
description: 'Accent color used across shortcodes',
-
default: '#10B981'
-
-
select 'button_style', 'Button Style',
-
[
-
['Rounded', 'rounded'],
-
['Square', 'square'],
-
['Pill', 'pill']
-
],
-
description: 'Default button border radius style',
-
default: 'rounded'
-
-
number 'button_padding', 'Button Padding (px)',
-
description: 'Internal padding for buttons',
-
default: 12,
-
min: 4,
-
max: 32
-
end
-
-
section 'Gallery Settings', description: 'Configure gallery shortcode behavior' do
-
select 'gallery_layout', 'Default Layout',
-
[
-
['Grid', 'grid'],
-
['Masonry', 'masonry'],
-
['Carousel', 'carousel']
-
],
-
default: 'grid'
-
-
number 'gallery_columns', 'Columns',
-
description: 'Number of columns in grid layout',
-
default: 3,
-
min: 1,
-
max: 6
-
-
number 'gallery_spacing', 'Spacing (px)',
-
description: 'Gap between gallery items',
-
default: 16,
-
min: 0,
-
max: 48
-
-
checkbox 'gallery_lightbox', 'Enable Lightbox',
-
description: 'Open images in a lightbox when clicked',
-
default: true
-
-
checkbox 'gallery_lazy_load', 'Lazy Loading',
-
description: 'Lazy load gallery images for better performance',
-
default: true
-
end
-
-
section 'Alert/Callout Settings', description: 'Configure alert box shortcodes' do
-
radio 'alert_style', 'Alert Style',
-
[
-
['Minimal', 'minimal'],
-
['Bordered', 'bordered'],
-
['Filled', 'filled']
-
],
-
default: 'bordered'
-
-
checkbox 'alert_dismissible', 'Dismissible',
-
description: 'Allow users to close alerts',
-
default: true
-
-
checkbox 'alert_icons', 'Show Icons',
-
description: 'Display icons in alert boxes',
-
default: true
-
end
-
-
section 'Video Settings', description: 'Configure video shortcode options' do
-
checkbox 'video_responsive', 'Responsive',
-
description: 'Make videos responsive (16:9 aspect ratio)',
-
default: true
-
-
checkbox 'video_autoplay', 'Auto-play by Default',
-
description: 'Videos auto-play when page loads (not recommended)',
-
default: false
-
-
checkbox 'video_controls', 'Show Controls',
-
description: 'Display video playback controls',
-
default: true
-
-
select 'video_preload', 'Preload Strategy',
-
[
-
['None', 'none'],
-
['Metadata', 'metadata'],
-
['Auto', 'auto']
-
],
-
description: 'How much of the video to preload',
-
default: 'metadata'
-
end
-
-
section 'Advanced Options', description: 'Advanced configuration' do
-
code 'custom_css', 'Custom CSS',
-
description: 'Add custom CSS for shortcodes',
-
language: 'css',
-
placeholder: '.my-shortcode { color: red; }'
-
-
textarea 'custom_javascript', 'Custom JavaScript',
-
description: 'Add custom JavaScript for shortcodes (use carefully!)',
-
rows: 6,
-
placeholder: 'console.log("Shortcodes loaded");'
-
-
checkbox 'debug_mode', 'Debug Mode',
-
description: 'Log shortcode rendering details to console',
-
default: false
-
-
checkbox 'cache_output', 'Cache Output',
-
description: 'Cache rendered shortcode output for performance',
-
default: true
-
end
-
end
-
-
def initialize
-
super
-
register_shortcodes if get_setting('enabled', true)
-
end
-
-
def activate
-
super
-
Rails.logger.info "Advanced Shortcodes activated with schema settings"
-
end
-
-
private
-
-
def register_shortcodes
-
# Button shortcode
-
register_shortcode('button') do |atts, content|
-
atts = {
-
'url' => '#',
-
'color' => get_setting('button_color', '#3B82F6'),
-
'style' => get_setting('button_style', 'rounded'),
-
'size' => 'medium'
-
}.merge(atts || {})
-
-
radius = case atts['style']
-
when 'pill' then '9999px'
-
when 'square' then '0'
-
else '6px'
-
end
-
-
padding = get_setting('button_padding', 12)
-
-
<<~HTML
-
<a href="#{atts['url']}"
-
class="inline-block"
-
style="background: #{atts['color']}; color: white; padding: #{padding}px #{padding * 2}px; border-radius: #{radius}; text-decoration: none; font-weight: 500;">
-
#{content}
-
</a>
-
HTML
-
end
-
-
# Gallery shortcode
-
register_shortcode('gallery') do |atts, content|
-
layout = get_setting('gallery_layout', 'grid')
-
columns = get_setting('gallery_columns', 3)
-
spacing = get_setting('gallery_spacing', 16)
-
-
<<~HTML
-
<div class="gallery-#{layout}" style="display: grid; grid-template-columns: repeat(#{columns}, 1fr); gap: #{spacing}px;">
-
<!-- Gallery items would go here -->
-
<div style="text-align: center; padding: 20px; background: #f3f4f6; border-radius: 8px;">
-
Gallery placeholder (#{columns} columns)
-
</div>
-
</div>
-
HTML
-
end
-
-
# Alert shortcode
-
register_shortcode('alert') do |atts, content|
-
atts = {
-
'type' => 'info',
-
'style' => get_setting('alert_style', 'bordered'),
-
'dismissible' => get_setting('alert_dismissible', true)
-
}.merge(atts || {})
-
-
colors = {
-
'info' => '#3B82F6',
-
'success' => '#10B981',
-
'warning' => '#F59E0B',
-
'error' => '#EF4444'
-
}
-
-
color = colors[atts['type']] || colors['info']
-
-
<<~HTML
-
<div class="alert alert-#{atts['type']}" style="border-left: 4px solid #{color}; background: rgba(59, 130, 246, 0.1); padding: 1rem; border-radius: 0.5rem; margin: 1rem 0;">
-
#{content}
-
#{atts['dismissible'] ? '<button onclick="this.parentElement.remove()" style="float: right;">×</button>' : ''}
-
</div>
-
HTML
-
end
-
-
# Video shortcode
-
register_shortcode('video') do |atts, content|
-
atts = {
-
'src' => '',
-
'responsive' => get_setting('video_responsive', true),
-
'autoplay' => get_setting('video_autoplay', false),
-
'controls' => get_setting('video_controls', true)
-
}.merge(atts || {})
-
-
if atts['responsive']
-
<<~HTML
-
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
-
<video src="#{atts['src']}"
-
#{atts['controls'] ? 'controls' : ''}
-
#{atts['autoplay'] ? 'autoplay' : ''}
-
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
-
</video>
-
</div>
-
HTML
-
else
-
<<~HTML
-
<video src="#{atts['src']}"
-
#{atts['controls'] ? 'controls' : ''}
-
#{atts['autoplay'] ? 'autoplay' : ''}
-
style="max-width: 100%;">
-
</video>
-
HTML
-
end
-
end
-
-
Rails.logger.info "Advanced Shortcodes registered with schema-based settings"
-
end
-
end
-
-
# Auto-initialize
-
if Plugin.exists?(name: 'Advanced Shortcodes', active: true)
-
AdvancedShortcodes.new
-
end
-
class AiSeo < Railspress::PluginBase
-
plugin_name 'AI SEO'
-
plugin_version '1.0.0'
-
plugin_description 'Automatically generate and optimize SEO meta tags using AI'
-
plugin_author 'RailsPress Team'
-
-
# Comprehensive settings schema for AI SEO
-
settings_schema do
-
section 'AI Provider', description: 'Configure your AI service provider' do
-
select 'ai_provider', 'AI Provider',
-
[
-
['OpenAI (GPT-4, GPT-3.5)', 'openai'],
-
['Anthropic (Claude)', 'anthropic'],
-
['Google (Gemini)', 'google'],
-
['Custom API', 'custom']
-
],
-
description: 'Choose your AI service provider',
-
required: true,
-
default: 'openai'
-
-
text 'api_key', 'API Key',
-
description: 'Your AI provider API key',
-
required: true,
-
placeholder: 'sk-...'
-
-
select 'model', 'Model',
-
[
-
['GPT-4 Turbo', 'gpt-4-turbo-preview'],
-
['GPT-4', 'gpt-4'],
-
['GPT-3.5 Turbo', 'gpt-3.5-turbo'],
-
['Claude 3 Opus', 'claude-3-opus-20240229'],
-
['Claude 3 Sonnet', 'claude-3-sonnet-20240229'],
-
['Claude 3 Haiku', 'claude-3-haiku-20240307'],
-
['Gemini Pro', 'gemini-pro']
-
],
-
description: 'Select the AI model to use',
-
default: 'gpt-3.5-turbo'
-
-
url 'custom_api_url', 'Custom API URL',
-
description: 'Custom API endpoint (only if using Custom API)',
-
placeholder: 'https://api.example.com/v1/chat'
-
end
-
-
section 'Auto-Generation Settings', description: 'Configure when and how SEO is generated' do
-
checkbox 'auto_generate_on_save', 'Auto-Generate on Save',
-
description: 'Automatically generate SEO when content is saved',
-
default: true
-
-
checkbox 'auto_generate_on_publish', 'Auto-Generate on Publish',
-
description: 'Generate SEO when content is published',
-
default: true
-
-
checkbox 'overwrite_existing', 'Overwrite Existing Meta Tags',
-
description: 'Replace existing meta tags with AI-generated ones',
-
default: false
-
-
checkbox 'generate_meta_title', 'Generate Meta Title',
-
description: 'Auto-generate meta title',
-
default: true
-
-
checkbox 'generate_meta_description', 'Generate Meta Description',
-
description: 'Auto-generate meta description',
-
default: true
-
-
checkbox 'generate_meta_keywords', 'Generate Meta Keywords',
-
description: 'Auto-generate meta keywords',
-
default: true
-
-
checkbox 'generate_og_tags', 'Generate Open Graph Tags',
-
description: 'Auto-generate Open Graph title and description',
-
default: true
-
-
checkbox 'generate_twitter_tags', 'Generate Twitter Card Tags',
-
description: 'Auto-generate Twitter card metadata',
-
default: true
-
-
checkbox 'generate_focus_keyphrase', 'Generate Focus Keyphrase',
-
description: 'Identify and set focus keyphrase',
-
default: true
-
end
-
-
section 'SEO Guidelines', description: 'Configure SEO best practices and limits' do
-
number 'meta_title_max_length', 'Meta Title Max Length',
-
description: 'Maximum characters for meta title',
-
default: 60,
-
min: 30,
-
max: 100
-
-
number 'meta_description_max_length', 'Meta Description Max Length',
-
description: 'Maximum characters for meta description',
-
default: 160,
-
min: 100,
-
max: 320
-
-
number 'meta_keywords_count', 'Number of Keywords',
-
description: 'How many keywords to generate',
-
default: 5,
-
min: 3,
-
max: 10
-
-
select 'tone', 'Content Tone',
-
[
-
['Professional', 'professional'],
-
['Casual', 'casual'],
-
['Technical', 'technical'],
-
['Marketing', 'marketing'],
-
['Educational', 'educational']
-
],
-
description: 'Tone for meta descriptions',
-
default: 'professional'
-
end
-
-
section 'Content Analysis', description: 'AI content analysis settings' do
-
checkbox 'analyze_readability', 'Analyze Readability',
-
description: 'Check content readability score',
-
default: true
-
-
checkbox 'analyze_keyword_density', 'Analyze Keyword Density',
-
description: 'Calculate keyword density',
-
default: true
-
-
checkbox 'analyze_sentiment', 'Analyze Sentiment',
-
description: 'Determine content sentiment',
-
default: false
-
-
checkbox 'suggest_improvements', 'Suggest Improvements',
-
description: 'Provide SEO improvement suggestions',
-
default: true
-
end
-
-
section 'Rate Limiting', description: 'Control API usage' do
-
number 'max_requests_per_hour', 'Max Requests Per Hour',
-
description: 'Limit API calls to prevent excessive usage',
-
default: 100,
-
min: 10,
-
max: 1000
-
-
number 'retry_attempts', 'Retry Attempts',
-
description: 'Number of retries on API failure',
-
default: 3,
-
min: 1,
-
max: 5
-
-
number 'timeout_seconds', 'Timeout (seconds)',
-
description: 'API request timeout',
-
default: 30,
-
min: 10,
-
max: 120
-
end
-
-
section 'Advanced', description: 'Advanced configuration options' do
-
textarea 'custom_prompt', 'Custom AI Prompt',
-
description: 'Customize the AI prompt (leave blank for default)',
-
rows: 6,
-
placeholder: 'You are an SEO expert. Analyze the following content and provide...'
-
-
checkbox 'log_ai_responses', 'Log AI Responses',
-
description: 'Save AI responses for debugging',
-
default: false
-
-
checkbox 'use_cache', 'Use Response Cache',
-
description: 'Cache AI responses to reduce API calls',
-
default: true
-
-
number 'cache_ttl_hours', 'Cache TTL (hours)',
-
description: 'How long to cache responses',
-
default: 24,
-
min: 1,
-
max: 168
-
end
-
end
-
-
def initialize
-
super
-
register_hooks if enabled?
-
register_ui_blocks
-
end
-
-
def activate
-
super
-
validate_configuration
-
Rails.logger.info "AI SEO plugin activated"
-
end
-
-
def enabled?
-
get_setting('api_key').present?
-
end
-
-
# Main API: Generate SEO for content
-
def generate_seo_for(content_object)
-
return unless should_generate?(content_object)
-
-
begin
-
# Extract content
-
content_text = extract_content_text(content_object)
-
-
# Check rate limit
-
return if rate_limit_exceeded?
-
-
# Check cache
-
cache_key = cache_key_for(content_object)
-
if get_setting('use_cache', true) && cached_response = fetch_from_cache(cache_key)
-
return apply_seo_data(content_object, cached_response)
-
end
-
-
# Call AI API
-
ai_response = call_ai_api(content_text, content_object)
-
-
# Parse and apply SEO
-
seo_data = parse_ai_response(ai_response)
-
apply_seo_data(content_object, seo_data)
-
-
# Cache response
-
cache_response(cache_key, seo_data) if get_setting('use_cache', true)
-
-
# Log if enabled
-
log_ai_interaction(content_object, ai_response) if get_setting('log_ai_responses', false)
-
-
increment_request_count
-
-
Rails.logger.info "AI SEO generated for #{content_object.class.name} ##{content_object.id}"
-
true
-
rescue => e
-
Rails.logger.error "AI SEO generation failed: #{e.message}"
-
false
-
end
-
end
-
-
# Public API endpoint for manual generation
-
def self.generate_seo(content_type, content_id)
-
plugin = Railspress::PluginSystem.get_plugin('ai_seo')
-
return { success: false, error: 'Plugin not active' } unless plugin
-
-
content = find_content(content_type, content_id)
-
return { success: false, error: 'Content not found' } unless content
-
-
result = plugin.generate_seo_for(content)
-
-
if result
-
{
-
success: true,
-
meta_title: content.meta_title,
-
meta_description: content.meta_description,
-
meta_keywords: content.meta_keywords,
-
focus_keyphrase: content.focus_keyphrase
-
}
-
else
-
{ success: false, error: 'Generation failed' }
-
end
-
end
-
-
private
-
-
def register_hooks
-
# Hook into post/page save
-
if get_setting('auto_generate_on_save', true)
-
add_action('post_saved', 20) { |post| generate_seo_for(post) }
-
add_action('page_saved', 20) { |page| generate_seo_for(page) }
-
end
-
-
# Hook into publish
-
if get_setting('auto_generate_on_publish', true)
-
add_action('post_published', 20) { |post| generate_seo_for(post) }
-
add_action('page_published', 20) { |page| generate_seo_for(page) }
-
end
-
end
-
-
def register_ui_blocks
-
# Register a sidebar block for SEO analysis on post/page edit screens
-
register_block(:ai_seo_analyzer, {
-
label: 'AI SEO Analyzer',
-
description: 'AI-powered SEO analysis and optimization suggestions',
-
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
-
locations: [:post, :page],
-
position: :sidebar,
-
order: 5,
-
partial: 'plugins/ai_seo/analyzer_block',
-
can_render: ->(context) { context[:current_user]&.admin? || context[:current_user]&.editor? }
-
})
-
-
# Register a toolbar block for quick SEO actions
-
register_block(:ai_seo_toolbar, {
-
label: 'AI SEO Tools',
-
description: 'Quick SEO generation actions',
-
icon: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>',
-
locations: [:post, :page],
-
position: :toolbar,
-
order: 10,
-
partial: 'plugins/ai_seo/toolbar_block'
-
})
-
end
-
-
def should_generate?(content)
-
return false unless content.respond_to?(:meta_title)
-
-
# Check if we should overwrite
-
unless get_setting('overwrite_existing', false)
-
return false if content.meta_title.present?
-
end
-
-
# Check if content has substance
-
text = extract_content_text(content)
-
text.present? && text.length > 100
-
end
-
-
def extract_content_text(content)
-
text = []
-
text << content.title if content.respond_to?(:title)
-
-
if content.respond_to?(:content) && content.content.present?
-
text << content.content.to_plain_text
-
elsif content.respond_to?(:body)
-
text << content.body
-
end
-
-
text.join("\n\n").strip
-
end
-
-
def call_ai_api(content_text, content_object)
-
provider = get_setting('ai_provider', 'openai')
-
-
case provider
-
when 'openai'
-
call_openai_api(content_text, content_object)
-
when 'anthropic'
-
call_anthropic_api(content_text, content_object)
-
when 'google'
-
call_google_api(content_text, content_object)
-
when 'custom'
-
call_custom_api(content_text, content_object)
-
else
-
raise "Unsupported AI provider: #{provider}"
-
end
-
end
-
-
def call_openai_api(content_text, content_object)
-
require 'net/http'
-
require 'json'
-
-
api_key = get_setting('api_key')
-
model = get_setting('model', 'gpt-3.5-turbo')
-
-
uri = URI('https://api.openai.com/v1/chat/completions')
-
-
request = Net::HTTP::Post.new(uri)
-
request['Authorization'] = "Bearer #{api_key}"
-
request['Content-Type'] = 'application/json'
-
-
prompt = build_seo_prompt(content_text, content_object)
-
-
request.body = {
-
model: model,
-
messages: [
-
{ role: 'system', content: 'You are an expert SEO specialist. Generate optimized meta tags.' },
-
{ role: 'user', content: prompt }
-
],
-
temperature: 0.7,
-
max_tokens: 500
-
}.to_json
-
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: get_setting('timeout_seconds', 30)) do |http|
-
http.request(request)
-
end
-
-
JSON.parse(response.body)
-
end
-
-
def call_anthropic_api(content_text, content_object)
-
require 'net/http'
-
require 'json'
-
-
api_key = get_setting('api_key')
-
model = get_setting('model', 'claude-3-sonnet-20240229')
-
-
uri = URI('https://api.anthropic.com/v1/messages')
-
-
request = Net::HTTP::Post.new(uri)
-
request['x-api-key'] = api_key
-
request['anthropic-version'] = '2023-06-01'
-
request['Content-Type'] = 'application/json'
-
-
prompt = build_seo_prompt(content_text, content_object)
-
-
request.body = {
-
model: model,
-
max_tokens: 500,
-
messages: [
-
{ role: 'user', content: prompt }
-
]
-
}.to_json
-
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: get_setting('timeout_seconds', 30)) do |http|
-
http.request(request)
-
end
-
-
JSON.parse(response.body)
-
end
-
-
def call_google_api(content_text, content_object)
-
# Placeholder for Google Gemini API
-
{ error: 'Google Gemini API not yet implemented' }
-
end
-
-
def call_custom_api(content_text, content_object)
-
# Placeholder for custom API
-
{ error: 'Custom API not yet implemented' }
-
end
-
-
def build_seo_prompt(content_text, content_object)
-
custom_prompt = get_setting('custom_prompt')
-
return custom_prompt.gsub('{{content}}', content_text) if custom_prompt.present?
-
-
max_title = get_setting('meta_title_max_length', 60)
-
max_desc = get_setting('meta_description_max_length', 160)
-
keyword_count = get_setting('meta_keywords_count', 5)
-
tone = get_setting('tone', 'professional')
-
-
<<~PROMPT
-
Analyze the following content and generate SEO-optimized meta tags.
-
-
Content:
-
---
-
#{content_text.truncate(2000)}
-
---
-
-
Generate the following in JSON format:
-
{
-
"meta_title": "SEO-optimized title (max #{max_title} chars)",
-
"meta_description": "Compelling description (max #{max_desc} chars, #{tone} tone)",
-
"meta_keywords": "comma-separated keywords (#{keyword_count} keywords)",
-
"focus_keyphrase": "primary keyword phrase",
-
"og_title": "Social media optimized title",
-
"og_description": "Social media description",
-
"twitter_title": "Twitter card title",
-
"twitter_description": "Twitter card description",
-
"suggestions": ["improvement suggestion 1", "improvement suggestion 2"]
-
}
-
-
Guidelines:
-
- Meta title should be compelling and include the focus keyword
-
- Meta description should be action-oriented with a clear value proposition
-
- Keywords should be relevant and specific
-
- All fields should be within character limits
-
- Use #{tone} tone
-
-
Respond ONLY with valid JSON, no additional text.
-
PROMPT
-
end
-
-
def parse_ai_response(response)
-
# Handle OpenAI response format
-
if response['choices']&.first
-
content = response['choices'].first['message']['content']
-
-
# Extract JSON from response
-
json_match = content.match(/\{[\s\S]*\}/)
-
return JSON.parse(json_match[0]) if json_match
-
end
-
-
# Handle Anthropic response format
-
if response['content']&.first
-
content = response['content'].first['text']
-
-
json_match = content.match(/\{[\s\S]*\}/)
-
return JSON.parse(json_match[0]) if json_match
-
end
-
-
{}
-
rescue JSON::ParserError => e
-
Rails.logger.error "Failed to parse AI response: #{e.message}"
-
{}
-
end
-
-
def apply_seo_data(content, seo_data)
-
return unless seo_data.present?
-
-
content.meta_title = seo_data['meta_title'] if get_setting('generate_meta_title', true) && seo_data['meta_title']
-
content.meta_description = seo_data['meta_description'] if get_setting('generate_meta_description', true) && seo_data['meta_description']
-
content.meta_keywords = seo_data['meta_keywords'] if get_setting('generate_meta_keywords', true) && seo_data['meta_keywords']
-
content.focus_keyphrase = seo_data['focus_keyphrase'] if get_setting('generate_focus_keyphrase', true) && seo_data['focus_keyphrase']
-
-
if get_setting('generate_og_tags', true)
-
content.og_title = seo_data['og_title'] if seo_data['og_title']
-
content.og_description = seo_data['og_description'] if seo_data['og_description']
-
end
-
-
if get_setting('generate_twitter_tags', true)
-
content.twitter_title = seo_data['twitter_title'] if seo_data['twitter_title']
-
content.twitter_description = seo_data['twitter_description'] if seo_data['twitter_description']
-
end
-
-
content.save(validate: false)
-
end
-
-
def rate_limit_exceeded?
-
max_requests = get_setting('max_requests_per_hour', 100)
-
current_count = get_request_count
-
-
if current_count >= max_requests
-
Rails.logger.warn "AI SEO rate limit exceeded: #{current_count}/#{max_requests}"
-
return true
-
end
-
-
false
-
end
-
-
def get_request_count
-
key = "ai_seo_requests_#{Time.now.hour}"
-
Rails.cache.read(key) || 0
-
end
-
-
def increment_request_count
-
key = "ai_seo_requests_#{Time.now.hour}"
-
count = get_request_count + 1
-
Rails.cache.write(key, count, expires_in: 1.hour)
-
end
-
-
def cache_key_for(content)
-
"ai_seo_#{content.class.name.underscore}_#{content.id}_#{content.updated_at.to_i}"
-
end
-
-
def fetch_from_cache(cache_key)
-
Rails.cache.read(cache_key)
-
end
-
-
def cache_response(cache_key, seo_data)
-
ttl_hours = get_setting('cache_ttl_hours', 24)
-
Rails.cache.write(cache_key, seo_data, expires_in: ttl_hours.hours)
-
end
-
-
def log_ai_interaction(content, response)
-
Rails.logger.info "AI SEO Interaction Log:"
-
Rails.logger.info "Content: #{content.class.name} ##{content.id}"
-
Rails.logger.info "Response: #{response.to_json}"
-
end
-
-
def validate_configuration
-
unless get_setting('api_key').present?
-
Rails.logger.warn "AI SEO: API key not configured"
-
end
-
end
-
-
def self.find_content(content_type, content_id)
-
case content_type.to_s.downcase
-
when 'post'
-
Post.find_by(id: content_id)
-
when 'page'
-
Page.find_by(id: content_id)
-
else
-
nil
-
end
-
end
-
end
-
-
# Auto-initialize if active
-
if Plugin.exists?(name: 'AI SEO', active: true)
-
AiSeo.new
-
end
-
-
class EmailNotifications < Railspress::PluginBase
-
plugin_name 'Email Notifications'
-
plugin_version '2.0.0'
-
plugin_description 'Send email notifications for various events using schema-based settings'
-
plugin_author 'RailsPress Team'
-
-
# Define settings schema - this will auto-generate the admin UI!
-
settings_schema do
-
section 'General Settings', description: 'Configure basic email notification settings' do
-
checkbox 'enabled', 'Enable Email Notifications',
-
description: 'Turn on/off all email notifications',
-
default: true
-
-
email 'admin_email', 'Admin Email Address',
-
description: 'Email address to receive admin notifications',
-
required: true,
-
placeholder: 'admin@example.com'
-
-
text 'from_name', 'From Name',
-
description: 'The name that appears in the From field',
-
default: 'RailsPress',
-
placeholder: 'Your Site Name'
-
end
-
-
section 'Post Notifications', description: 'Configure notifications for posts' do
-
checkbox 'notify_on_new_post', 'New Post Created',
-
description: 'Send notification when a new post is created',
-
default: true
-
-
checkbox 'notify_on_post_published', 'Post Published',
-
description: 'Send notification when a post is published',
-
default: true
-
-
select 'post_notification_recipients', 'Recipients',
-
[
-
['Administrators Only', 'administrators'],
-
['All Editors', 'editors'],
-
['All Users', 'all']
-
],
-
description: 'Who should receive post notifications',
-
default: 'administrators'
-
end
-
-
section 'Comment Notifications', description: 'Configure notifications for comments' do
-
checkbox 'notify_on_new_comment', 'New Comment',
-
description: 'Send notification when a new comment is submitted',
-
default: true
-
-
checkbox 'notify_on_comment_approved', 'Comment Approved',
-
description: 'Send notification when a comment is approved',
-
default: false
-
-
checkbox 'notify_post_author', 'Notify Post Author',
-
description: 'Send notification to post author when someone comments',
-
default: true
-
end
-
-
section 'Advanced Settings', description: 'Advanced configuration options' do
-
number 'batch_size', 'Batch Size',
-
description: 'Number of emails to send per batch',
-
default: 10,
-
min: 1,
-
max: 100
-
-
number 'delay_between_batches', 'Delay Between Batches (seconds)',
-
description: 'Wait time between email batches to avoid rate limiting',
-
default: 5,
-
min: 0,
-
max: 60
-
-
textarea 'email_template', 'Custom Email Template',
-
description: 'Custom HTML email template (leave blank for default)',
-
rows: 8,
-
placeholder: '<html><body>{{content}}</body></html>'
-
end
-
end
-
-
# Initialization
-
def initialize
-
super
-
register_hooks if get_setting('enabled', true)
-
end
-
-
def activate
-
super
-
Rails.logger.info "Email Notifications plugin activated with schema-based settings"
-
end
-
-
private
-
-
def register_hooks
-
# Post notifications
-
if get_setting('notify_on_new_post', true)
-
add_action('post_created', 10) do |post|
-
send_post_created_notification(post)
-
end
-
end
-
-
if get_setting('notify_on_post_published', true)
-
add_action('post_published', 10) do |post|
-
send_post_published_notification(post)
-
end
-
end
-
-
# Comment notifications
-
if get_setting('notify_on_new_comment', true)
-
add_action('comment_created', 10) do |comment|
-
send_comment_notification(comment)
-
end
-
end
-
-
if get_setting('notify_post_author', true)
-
add_action('comment_created', 15) do |comment|
-
send_author_notification(comment)
-
end
-
end
-
end
-
-
def send_post_created_notification(post)
-
recipients = get_recipients
-
return if recipients.empty?
-
-
recipients.each do |user|
-
# TODO: Send email via ActionMailer
-
Rails.logger.info "Would send 'post created' email to #{user.email}"
-
end
-
end
-
-
def send_post_published_notification(post)
-
recipients = get_recipients
-
return if recipients.empty?
-
-
recipients.each do |user|
-
Rails.logger.info "Would send 'post published' email to #{user.email}"
-
end
-
end
-
-
def send_comment_notification(comment)
-
admin_email = get_setting('admin_email')
-
return unless admin_email
-
-
Rails.logger.info "Would send comment notification to #{admin_email}"
-
end
-
-
def send_author_notification(comment)
-
return unless comment.commentable.respond_to?(:user)
-
-
author = comment.commentable.user
-
return unless author
-
-
Rails.logger.info "Would send comment notification to post author: #{author.email}"
-
end
-
-
def get_recipients
-
recipient_type = get_setting('post_notification_recipients', 'administrators')
-
-
case recipient_type
-
when 'administrators'
-
User.administrator
-
when 'editors'
-
User.where(role: ['administrator', 'editor'])
-
when 'all'
-
User.all
-
else
-
User.administrator
-
end
-
end
-
end
-
-
# Auto-initialize if active
-
if Plugin.exists?(name: 'Email Notifications', active: true)
-
EmailNotifications.new
-
end
-
class HelloTupac < Railspress::PluginBase
-
plugin_name 'Hello Tupac!'
-
plugin_version '1.0.0'
-
plugin_description 'This is not just a plugin, it symbolizes the hope and enthusiasm of an entire generation summed up in two words sung most famously by Tupac Shakur: Keep ya head up.'
-
plugin_author 'RailsPress Team'
-
-
# Tupac Shakur quotes
-
TUPAC_QUOTES = [
-
"Reality is wrong. Dreams are for real.",
-
"Keep ya head up.",
-
"Only God can judge me.",
-
"I'm not perfect, but I'll always be real.",
-
"For every dark night, there's a brighter day.",
-
"They got money for war but can't feed the poor.",
-
"The only thing that comes to a sleeping man is dreams.",
-
"You gotta make a change.",
-
"Behind every sweet smile is a bitter sadness.",
-
"I'd rather die like a man than live like a coward.",
-
"I'm a reflection of the community.",
-
"I ain't mad at cha.",
-
"Ain't nothin' like the old school.",
-
"Even though you're fed up, keep your head up.",
-
"I'm gonna spark the brain that changes the world.",
-
"My only fear of death is coming back reincarnated.",
-
"Trust nobody.",
-
"It's just me against the world.",
-
"Out on bail, fresh out of jail, California dreamin'.",
-
"All eyez on me.",
-
"Alcohol and booty calls.",
-
"Cause life goes on.",
-
"With G's in my pocket.",
-
"Have a party at my funeral.",
-
"Until I get free.",
-
"I live my life in tha fast lane.",
-
"Life goes on homie.",
-
"Get money.",
-
"Evade b*ches.",
-
"Evade tricks."
-
].freeze unless defined?(TUPAC_QUOTES)
-
-
def activate
-
super
-
Rails.logger.info "Hello Tupac! plugin activated - Keep ya head up!"
-
-
# Register the admin sidebar hook
-
register_admin_sidebar_hook
-
end
-
-
def deactivate
-
super
-
Rails.logger.info "Hello Tupac! plugin deactivated"
-
end
-
-
private
-
-
def register_admin_sidebar_hook
-
# Add content to the admin right topbar (next to Go to Site button)
-
add_action('admin_right_topbar_content') do
-
render_topbar_quote
-
end
-
end
-
-
def render_topbar_quote
-
quote = TUPAC_QUOTES.sample
-
# Escape the quote for HTML attributes to prevent issues with quotes
-
escaped_quote = quote.gsub('"', '"').gsub("'", ''')
-
-
# Return HTML that will be rendered in the topbar
-
"<div class='hello-tupac-quote inline-flex items-center gap-2 px-3 py-1 bg-gradient-to-r from-purple-500/10 to-pink-500/10 border border-purple-500/20 rounded-lg text-xs text-purple-200 max-w-xs truncate cursor-help' title='#{escaped_quote} — Tupac Shakur' data-tooltip='#{escaped_quote} — Tupac Shakur'>
-
<svg class='w-3 h-3 text-purple-400 flex-shrink-0' fill='currentColor' viewBox='0 0 20 20'>
-
<path fill-rule='evenodd' d='M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z' clip-rule='evenodd'/>
-
</svg>
-
<span class='truncate'>#{quote}</span>
-
</div>".html_safe
-
end
-
-
# Helper method to get a random quote
-
def self.get_quote
-
TUPAC_QUOTES.sample
-
end
-
end
-
-
# Auto-initialize if active
-
if Plugin.exists?(name: 'Hello Tupac!', active: true)
-
HelloTupac.new
-
end
-
# Image Optimizer Plugin
-
# Automatically optimizes uploaded images
-
-
class ImageOptimizer < Railspress::PluginBase
-
plugin_name 'Image Optimizer'
-
plugin_version '1.0.0'
-
plugin_description 'Automatically optimize images on upload for better performance'
-
plugin_author 'RailsPress'
-
-
def activate
-
super
-
register_hooks
-
end
-
-
private
-
-
def register_hooks
-
# Hook into file upload process
-
add_action('media_uploaded', :optimize_image)
-
end
-
-
def optimize_image(medium)
-
return unless medium.image?
-
return unless medium.upload&.file&.attached?
-
-
# Check if optimization is enabled in settings
-
storage_config = StorageConfigurationService.new
-
return unless storage_config.auto_optimize_enabled?
-
-
# Check media settings
-
return unless SiteSetting.get('auto_optimize_images', false)
-
-
# Queue optimization job
-
OptimizeImageJob.perform_later(medium_id: medium.id)
-
-
Rails.logger.info "Queued image optimization for medium #{medium.id}"
-
end
-
end
-
-
ImageOptimizer.new
-
-
-
-
-
-
-
-
-
# Reading Time Plugin
-
# Calculates estimated reading time for posts
-
-
class ReadingTime < Railspress::PluginBase
-
plugin_name 'Reading Time'
-
plugin_version '1.0.0'
-
plugin_description 'Displays estimated reading time for posts and pages'
-
plugin_author 'RailsPress'
-
-
WORDS_PER_MINUTE = 200
-
-
def activate
-
super
-
inject_helper_methods
-
end
-
-
private
-
-
def inject_helper_methods
-
ApplicationController.helper_method :reading_time if defined?(ApplicationController)
-
end
-
-
# Calculate reading time for content
-
def self.calculate(content)
-
return 0 if content.blank?
-
-
# Strip HTML tags and count words
-
text = ActionView::Base.full_sanitizer.sanitize(content.to_s)
-
word_count = text.split.size
-
-
minutes = (word_count.to_f / WORDS_PER_MINUTE).ceil
-
minutes < 1 ? 1 : minutes
-
end
-
-
# Format reading time
-
def self.format(minutes)
-
if minutes == 1
-
"1 min read"
-
else
-
"#{minutes} min read"
-
end
-
end
-
end
-
-
# Helper module
-
module ReadingTimeHelper
-
def reading_time(content)
-
minutes = ReadingTime.calculate(content)
-
ReadingTime.format(minutes)
-
end
-
-
def reading_time_minutes(content)
-
ReadingTime.calculate(content)
-
end
-
end
-
-
# Include helper
-
if defined?(ApplicationController)
-
ApplicationController.helper(ReadingTimeHelper)
-
end
-
-
ReadingTime.new
-
-
-
-
-
-
-
-
-
# Related Posts Plugin
-
# Adds related posts functionality based on categories and tags
-
-
class RelatedPosts < Railspress::PluginBase
-
plugin_name 'Related Posts'
-
plugin_version '1.0.0'
-
plugin_description 'Displays related posts based on categories and tags'
-
plugin_author 'RailsPress'
-
-
# Define settings schema
-
settings_schema do
-
section 'General Settings' do
-
number 'count', 'Number of related posts to show', default: 5, min: 1, max: 20
-
checkbox 'show_excerpt', 'Show post excerpt', default: true
-
checkbox 'show_thumbnail', 'Show post thumbnail', default: true
-
select 'sort_by', 'Sort related posts by',
-
options: [
-
['Relevance (default)', 'relevance'],
-
['Date (newest first)', 'date_desc'],
-
['Date (oldest first)', 'date_asc'],
-
['Title (A-Z)', 'title_asc'],
-
['Title (Z-A)', 'title_desc']
-
],
-
default: 'relevance'
-
end
-
-
section 'Display Options' do
-
text 'title', 'Section title', default: 'Related Posts', placeholder: 'e.g., You might also like...'
-
checkbox 'show_in_single_post', 'Show in single post pages', default: true
-
checkbox 'show_in_archive', 'Show in archive pages', default: false
-
select 'layout', 'Display layout',
-
options: [
-
['List (default)', 'list'],
-
['Grid (2 columns)', 'grid_2'],
-
['Grid (3 columns)', 'grid_3'],
-
['Grid (4 columns)', 'grid_4']
-
],
-
default: 'list'
-
end
-
-
section 'Advanced' do
-
checkbox 'include_same_author', 'Include posts by same author', default: false
-
checkbox 'exclude_sticky', 'Exclude sticky posts', default: true
-
number 'cache_duration', 'Cache duration (minutes)', default: 60, min: 0, max: 1440
-
end
-
end
-
-
def activate
-
super
-
register_hooks
-
inject_helper_methods
-
end
-
-
private
-
-
def register_hooks
-
# Add filter to modify related posts logic
-
add_filter('related_posts_count', :get_related_posts_count)
-
add_filter('related_posts_query', :enhance_related_posts_query)
-
end
-
-
def inject_helper_methods
-
# Add helper method to ApplicationController
-
ApplicationController.helper_method :get_related_posts if defined?(ApplicationController)
-
end
-
-
def get_related_posts_count(default_count)
-
get_setting('count', default_count)
-
end
-
-
def enhance_related_posts_query(posts)
-
# Can add additional filtering or sorting logic
-
posts
-
end
-
-
# Public method that can be called from views
-
def self.find_related(post, limit = 5)
-
return Post.none unless post
-
-
# Find posts with matching categories or tags
-
related_by_category = Post.published
-
.joins(:categories)
-
.where(categories: { id: post.category_ids })
-
.where.not(id: post.id)
-
.distinct
-
-
related_by_tag = Post.published
-
.joins(:tags)
-
.where(tags: { id: post.tag_ids })
-
.where.not(id: post.id)
-
.distinct
-
-
# Combine and prioritize by category match
-
(related_by_category.to_a + related_by_tag.to_a)
-
.uniq
-
.sort_by { |p| -matching_score(post, p) }
-
.first(limit)
-
end
-
-
def self.matching_score(post1, post2)
-
category_matches = (post1.category_ids & post2.category_ids).count * 2
-
tag_matches = (post1.tag_ids & post2.tag_ids).count
-
category_matches + tag_matches
-
end
-
end
-
-
# Helper method for views
-
module RelatedPostsHelper
-
def get_related_posts(post, limit = 5)
-
RelatedPosts.find_related(post, limit)
-
end
-
end
-
-
# Include helper in ApplicationController
-
if defined?(ApplicationController)
-
ApplicationController.helper(RelatedPostsHelper)
-
end
-
-
RelatedPosts.new
-
-
-
-
-
-
-
-
-
# SEO Optimizer Pro Plugin for RailsPress
-
# Enhances SEO capabilities with sitemaps, meta tags, and analytics
-
-
class SeoOptimizerPro < Railspress::PluginBase
-
plugin_name 'SEO Optimizer Pro'
-
plugin_version '2.5.0'
-
plugin_description 'Complete SEO solution with XML sitemaps, meta tag management, and analytics'
-
plugin_author 'RailsPress Team'
-
-
def activate
-
super
-
Rails.logger.info "SEO Optimizer Pro activated"
-
-
# Register hooks
-
register_seo_hooks
-
-
# Generate sitemap on activation
-
GenerateSitemapJob.perform_later if defined?(GenerateSitemapJob)
-
end
-
-
def deactivate
-
super
-
Rails.logger.info "SEO Optimizer Pro deactivated"
-
end
-
-
private
-
-
def register_seo_hooks
-
# Add filter to modify page titles
-
add_filter('page_title', :enhance_page_title)
-
-
# Add action hook for tracking page views
-
add_action('post_viewed', :track_page_view)
-
end
-
-
def enhance_page_title(title)
-
site_name = SiteSetting.get('site_title', 'RailsPress')
-
"#{title} | #{site_name}"
-
end
-
-
def track_page_view(post_id)
-
# Track in analytics
-
Rails.logger.info "Tracking view for post #{post_id}"
-
end
-
-
# Plugin-specific methods
-
def generate_sitemap
-
# Generate XML sitemap
-
posts = Post.published
-
pages = Page.published
-
-
# Build sitemap XML
-
# This would be implemented based on sitemap format
-
end
-
-
def get_google_analytics_id
-
get_setting('google_analytics_id', '')
-
end
-
-
def sitemap_enabled?
-
get_setting('sitemap_enabled', false)
-
end
-
end
-
-
# Initialize the plugin
-
SeoOptimizerPro.new
-
-
-
-
-
-
-
-
-
# Sitemap Generator Plugin
-
# Automatically generates XML sitemaps for SEO
-
-
class SitemapGenerator < Railspress::PluginBase
-
plugin_name 'Sitemap Generator'
-
plugin_version '1.0.0'
-
plugin_description 'Automatically generates XML sitemaps for better SEO'
-
plugin_author 'RailsPress'
-
-
def activate
-
super
-
register_hooks
-
generate_sitemap
-
end
-
-
private
-
-
def register_hooks
-
# Generate sitemap when posts/pages are published
-
add_action('post_published', :generate_sitemap)
-
add_action('page_published', :generate_sitemap)
-
end
-
-
def generate_sitemap
-
sitemap_content = build_sitemap_xml
-
sitemap_path = Rails.public_path.join('sitemap.xml')
-
-
File.write(sitemap_path, sitemap_content)
-
Rails.logger.info "Sitemap generated at #{sitemap_path}"
-
rescue => e
-
Rails.logger.error "Failed to generate sitemap: #{e.message}"
-
end
-
-
def build_sitemap_xml
-
xml = []
-
xml << '<?xml version="1.0" encoding="UTF-8"?>'
-
xml << '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
-
-
# Add homepage
-
xml << build_url_entry('/', priority: '1.0', changefreq: 'daily')
-
-
# Add posts
-
Post.published.find_each do |post|
-
xml << build_url_entry(
-
"/blog/#{post.slug}",
-
lastmod: post.updated_at,
-
priority: '0.8',
-
changefreq: 'weekly'
-
)
-
end
-
-
# Add pages
-
Page.published.find_each do |page|
-
xml << build_url_entry(
-
"/#{page.slug}",
-
lastmod: page.updated_at,
-
priority: '0.6',
-
changefreq: 'monthly'
-
)
-
end
-
-
# Add category archives
-
Term.for_taxonomy('category').find_each do |category|
-
xml << build_url_entry(
-
"/category/#{category.slug}",
-
priority: '0.5',
-
changefreq: 'weekly'
-
)
-
end
-
-
xml << '</urlset>'
-
xml.join("\n")
-
end
-
-
def build_url_entry(path, lastmod: nil, priority: '0.5', changefreq: 'monthly')
-
base_url = get_setting('base_url', 'http://localhost:3000')
-
-
entry = [" <url>"]
-
entry << " <loc>#{base_url}#{path}</loc>"
-
entry << " <lastmod>#{lastmod.strftime('%Y-%m-%d')}</lastmod>" if lastmod
-
entry << " <changefreq>#{changefreq}</changefreq>"
-
entry << " <priority>#{priority}</priority>"
-
entry << " </url>"
-
-
entry.join("\n")
-
end
-
end
-
-
SitemapGenerator.new
-
-
-
-
-
# SlickForms - Beautiful Form Builder for RailsPress
-
#
-
# Create and manage forms with drag-and-drop interface
-
# Features:
-
# - Form builder with basic fields (text, email, textarea, checkbox, select)
-
# - Submission management
-
# - Email notifications
-
# - Anti-spam protection
-
# - Export submissions
-
-
class SlickForms < Railspress::PluginBase
-
plugin_name 'Slick Forms'
-
plugin_version '1.0.0'
-
plugin_description 'Beautiful drag-and-drop form builder with submission management'
-
plugin_author 'RailsPress'
-
plugin_url 'https://railspress.com/plugins/slick-forms'
-
plugin_license 'GPL-2.0'
-
-
def setup
-
# Settings
-
define_setting :from_email,
-
type: 'string',
-
label: 'From Email',
-
description: 'Email address for form notifications',
-
default: 'noreply@example.com',
-
required: true
-
-
define_setting :enable_recaptcha,
-
type: 'boolean',
-
label: 'Enable reCAPTCHA',
-
description: 'Protect forms from spam',
-
default: false
-
-
define_setting :recaptcha_site_key,
-
type: 'string',
-
label: 'reCAPTCHA Site Key',
-
placeholder: '6Le...'
-
-
define_setting :recaptcha_secret_key,
-
type: 'string',
-
label: 'reCAPTCHA Secret Key',
-
placeholder: '6Le...'
-
-
define_setting :store_submissions,
-
type: 'boolean',
-
label: 'Store Submissions',
-
description: 'Save form submissions to database',
-
default: true
-
-
# Register admin pages
-
register_admin_page(
-
slug: 'forms',
-
title: 'All Forms',
-
menu_title: 'Forms',
-
icon: 'document',
-
callback: :render_forms_page
-
)
-
-
register_admin_page(
-
slug: 'submissions',
-
title: 'Form Submissions',
-
menu_title: 'Submissions',
-
icon: 'inbox',
-
callback: :render_submissions_page
-
)
-
-
register_admin_page(
-
slug: 'settings',
-
title: 'Form Settings',
-
menu_title: 'Settings',
-
icon: 'cog'
-
)
-
-
-
# Register admin routes (automatically scoped under /admin)
-
register_admin_routes do
-
namespace :slick_forms do
-
resources :forms do
-
member do
-
post :duplicate
-
get :preview
-
end
-
collection do
-
post :import
-
end
-
end
-
-
resources :submissions, only: [:index, :show, :destroy] do
-
collection do
-
get :export
-
post :bulk_action
-
end
-
end
-
end
-
end
-
-
# Register frontend routes (automatically scoped under /plugins)
-
register_frontend_routes do
-
# Public form submission endpoint
-
post 'submit/:form_id', to: 'slick_forms/submissions#create', as: 'slick_form_submit'
-
-
# Public form display
-
get 'form/:form_id', to: 'slick_forms/forms#show', as: 'slick_form_display'
-
get 'form/:form_id/embed', to: 'slick_forms/forms#embed', as: 'slick_form_embed'
-
end
-
-
# ========================================
-
# ENHANCED PLUGIN FEATURES DEMO
-
# ========================================
-
-
# Register webhooks for form events
-
register_webhook('form.submitted', 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK', {
-
method: 'POST',
-
headers: { 'Content-Type' => 'application/json' },
-
secret: 'your-webhook-secret'
-
})
-
-
register_webhook('form.spam_detected', 'https://api.example.com/spam-alert', {
-
method: 'POST',
-
retry_count: 5,
-
timeout: 10
-
})
-
-
# Register event listeners
-
on('user.registered') do |data|
-
log("New user registered: #{data[:user][:email]}", :info)
-
notify_admin("New user registration via form", :success, { user: data[:user] })
-
end
-
-
on('form.submission.failed') do |data|
-
log("Form submission failed: #{data[:error]}", :error)
-
notify_admin("Form submission failed", :error, {
-
form_id: data[:form_id],
-
error: data[:error]
-
})
-
end
-
-
# Register assets
-
register_stylesheet('slick_forms.css', { admin_only: true })
-
register_javascript('slick_forms.js', { admin_only: true })
-
register_stylesheet('slick_forms_frontend.css', { frontend_only: true })
-
register_javascript('slick_forms_frontend.js', { frontend_only: true })
-
-
# Register API endpoints
-
register_api_endpoint('GET', 'forms', { controller: 'api/slick_forms/forms', action: 'index' }, {
-
authentication: :token,
-
rate_limit: 100
-
})
-
-
register_api_endpoint('POST', 'submissions', { controller: 'api/slick_forms/submissions', action: 'create' }, {
-
authentication: :api_key,
-
rate_limit: 50
-
})
-
-
# Register theme templates
-
register_theme_template('contact_form', <<~LIQUID, { type: :page })
-
<div class="slick-form-container">
-
<h2>{{ form.title }}</h2>
-
<form action="/plugins/slick_forms/submit/{{ form.id }}" method="post">
-
{% for field in form.fields %}
-
<div class="form-field">
-
<label>{{ field.label }}</label>
-
{% case field.type %}
-
{% when 'text' %}
-
<input type="text" name="{{ field.name }}" required="{{ field.required }}">
-
{% when 'email' %}
-
<input type="email" name="{{ field.name }}" required="{{ field.required }}">
-
{% when 'textarea' %}
-
<textarea name="{{ field.name }}" required="{{ field.required }}"></textarea>
-
{% endcase %}
-
</div>
-
{% endfor %}
-
<button type="submit">Submit</button>
-
</form>
-
</div>
-
LIQUID
-
-
# Register theme settings
-
register_theme_setting('form_style', :select, {
-
label: 'Form Style',
-
description: 'Choose the form styling',
-
default: 'modern',
-
options: { 'modern' => 'Modern', 'classic' => 'Classic', 'minimal' => 'Minimal' }
-
})
-
-
register_theme_setting('show_labels', :boolean, {
-
label: 'Show Field Labels',
-
description: 'Display field labels above inputs',
-
default: true
-
})
-
-
# Register custom validators
-
register_validator('email_domain') do |email|
-
allowed_domains = get_setting(:allowed_email_domains, []).split(',')
-
return true if allowed_domains.empty?
-
-
domain = email.split('@').last
-
allowed_domains.include?(domain.strip)
-
end
-
-
register_validator('strong_password') do |password|
-
return false if password.length < 8
-
return false unless password.match?(/[A-Z]/) # Uppercase
-
return false unless password.match?(/[a-z]/) # Lowercase
-
return false unless password.match?(/\d/) # Number
-
return false unless password.match?(/[^A-Za-z0-9]/) # Special char
-
true
-
end
-
-
# Register custom commands
-
register_command('cleanup', 'Clean up old form submissions') do
-
cutoff_date = 6.months.ago
-
deleted_count = SlickFormSubmission.where('created_at < ?', cutoff_date).delete_all
-
puts "Cleaned up #{deleted_count} old submissions"
-
end
-
-
register_command('stats', 'Show form statistics') do
-
total_forms = SlickForm.count
-
total_submissions = SlickFormSubmission.count
-
spam_count = SlickFormSubmission.where(spam: true).count
-
-
puts "=== SlickForms Statistics ==="
-
puts "Total Forms: #{total_forms}"
-
puts "Total Submissions: #{total_submissions}"
-
puts "Spam Submissions: #{spam_count}"
-
puts "Legitimate Submissions: #{total_submissions - spam_count}"
-
end
-
-
# Schedule recurring tasks
-
schedule_task('cleanup_spam', '0 2 * * *') do
-
# Clean up spam submissions older than 30 days
-
cutoff_date = 30.days.ago
-
spam_count = SlickFormSubmission.where(spam: true, created_at: ...cutoff_date).delete_all
-
log("Cleaned up #{spam_count} old spam submissions", :info)
-
end
-
-
schedule_task('generate_reports', '0 8 * * 1') do
-
# Generate weekly reports
-
week_start = 1.week.ago.beginning_of_day
-
week_end = Time.current.end_of_day
-
-
submissions = SlickFormSubmission.where(created_at: week_start..week_end)
-
forms_used = submissions.distinct.count(:slick_form_id)
-
-
notify_admin("Weekly Form Report: #{submissions.count} submissions across #{forms_used} forms", :info, {
-
period: 'weekly',
-
submissions: submissions.count,
-
forms: forms_used
-
})
-
end
-
-
# Background job for email notifications
-
create_job 'NotificationJob' do
-
def perform(submission_id)
-
submission = find_submission(submission_id)
-
return unless submission
-
-
# Send email notification
-
SlickFormsMailer.new_submission(submission).deliver_later
-
Rails.logger.info "Sent notification for submission ##{submission_id}"
-
end
-
-
private
-
-
def find_submission(id)
-
# Implementation would fetch from database
-
nil
-
end
-
end
-
-
# Hooks
-
add_action('form_submitted', :process_submission)
-
add_filter('form_fields', :add_honeypot_field)
-
-
log("SlickForms initialized successfully")
-
end
-
-
def activate
-
super
-
create_forms_table
-
create_submissions_table
-
log("SlickForms activated and tables created")
-
end
-
-
def deactivate
-
super
-
log("SlickForms deactivated")
-
end
-
-
def uninstall
-
super
-
drop_tables if get_setting(:delete_data_on_uninstall, false)
-
log("SlickForms uninstalled")
-
end
-
-
# Render forms management page
-
def render_forms_page
-
{
-
title: 'All Forms',
-
forms: get_all_forms,
-
stats: {
-
total_forms: get_all_forms.size,
-
total_submissions: get_submission_count,
-
active_forms: get_all_forms.count { |f| f[:active] }
-
}
-
}
-
end
-
-
# Render submissions page
-
def render_submissions_page
-
{
-
title: 'Form Submissions',
-
submissions: get_recent_submissions(50),
-
stats: {
-
total: get_submission_count,
-
today: get_submissions_today_count,
-
this_week: get_submissions_week_count
-
}
-
}
-
end
-
-
-
# Get plugin metadata
-
def metadata
-
{
-
name: name,
-
version: version,
-
description: description,
-
author: author,
-
supported_fields: supported_fields
-
}
-
end
-
-
# Supported field types (Free version)
-
def supported_fields
-
[
-
{ type: 'text', label: 'Text Field', icon: '📝' },
-
{ type: 'email', label: 'Email Field', icon: '📧' },
-
{ type: 'textarea', label: 'Textarea', icon: '📄' },
-
{ type: 'select', label: 'Dropdown', icon: '📋' },
-
{ type: 'checkbox', label: 'Checkbox', icon: '☑️' },
-
{ type: 'radio', label: 'Radio Buttons', icon: '🔘' },
-
{ type: 'number', label: 'Number Field', icon: '🔢' },
-
{ type: 'url', label: 'URL Field', icon: '🔗' }
-
]
-
end
-
-
# Free version features
-
def features
-
{
-
'Drag and Drop Builder' => true,
-
'AI Form Builder' => true,
-
'Conditional Logic' => true,
-
'Advanced Form Styler' => false,
-
'Numeric Calculation' => false,
-
'Unique Entry Validation' => true,
-
'Multi-Step Forms' => false,
-
'Conversational Forms' => true,
-
'Advanced Post Creation' => false,
-
'Payment' => true,
-
'Coupon' => false,
-
'Inventory' => false,
-
'Address Autocomplete' => false,
-
'Spam Protection' => true,
-
'Quiz and Survey' => false,
-
'Multi-column Form' => true,
-
'Version History' => true,
-
'Fully Responsive' => true,
-
'Personality Quiz' => false,
-
'CSS Ready Classes' => true,
-
'Keyboard Navigation' => true,
-
'Undo/Redo' => true,
-
'Default Input Fields Value' => true,
-
'Accessibility' => true
-
}
-
end
-
-
# Implement Free Features
-
-
def drag_drop_builder_enabled?
-
true # Always available in free
-
end
-
-
def ai_form_builder_enabled?
-
true # Basic AI form generation
-
end
-
-
def conditional_logic_enabled?
-
true # Basic show/hide fields
-
end
-
-
def unique_entry_validation_enabled?
-
true # Email uniqueness, etc.
-
end
-
-
def conversational_forms_enabled?
-
true # Basic conversational flow
-
end
-
-
def payment_enabled?
-
true # Basic payment processing
-
end
-
-
def spam_protection_enabled?
-
true # Honeypot, basic validation
-
end
-
-
def multi_column_forms_enabled?
-
true # 2-column layouts
-
end
-
-
def version_history_enabled?
-
true # Basic form versioning
-
end
-
-
def fully_responsive_enabled?
-
true # Mobile-friendly forms
-
end
-
-
def css_ready_classes_enabled?
-
true # CSS classes for styling
-
end
-
-
def keyboard_navigation_enabled?
-
true # Tab navigation
-
end
-
-
def undo_redo_enabled?
-
true # Basic undo/redo in builder
-
end
-
-
def default_input_values_enabled?
-
true # Default field values
-
end
-
-
def accessibility_enabled?
-
true # ARIA labels, screen reader support
-
end
-
-
# Pro Features (disabled in free)
-
def advanced_form_styler_enabled?
-
false
-
end
-
-
def numeric_calculation_enabled?
-
false
-
end
-
-
def multi_step_forms_enabled?
-
false
-
end
-
-
def advanced_post_creation_enabled?
-
false
-
end
-
-
def coupon_enabled?
-
false
-
end
-
-
def inventory_enabled?
-
false
-
end
-
-
def address_autocomplete_enabled?
-
false
-
end
-
-
def quiz_survey_enabled?
-
false
-
end
-
-
def personality_quiz_enabled?
-
false
-
end
-
-
private
-
-
def process_submission(form_id, data)
-
log("Processing submission for form #{form_id}")
-
-
# Apply spam protection
-
if spam_protection_enabled?
-
return false if detect_spam(data)
-
end
-
-
# Apply unique entry validation
-
if unique_entry_validation_enabled?
-
return false unless validate_unique_entries(data)
-
end
-
-
# Process payment if enabled
-
if payment_enabled? && data[:payment_required]
-
process_payment(data)
-
end
-
-
# Save submission
-
save_submission(form_id, data)
-
-
log("Submission processed successfully for form #{form_id}")
-
true
-
end
-
-
def detect_spam(data)
-
# Check honeypot field
-
return true if data[:website].present?
-
-
# Basic spam detection
-
spam_keywords = ['viagra', 'casino', 'loan', 'free money']
-
content = data.values.join(' ').downcase
-
spam_keywords.any? { |keyword| content.include?(keyword) }
-
end
-
-
def validate_unique_entries(data)
-
# Check for unique email addresses
-
if data[:email].present?
-
existing = get_submissions_by_email(data[:email])
-
return false if existing.any?
-
end
-
true
-
end
-
-
def process_payment(data)
-
log("Processing payment for submission")
-
# Basic payment processing logic
-
end
-
-
def save_submission(form_id, data)
-
log("Saving submission for form #{form_id}")
-
# Save to database
-
end
-
-
def get_submissions_by_email(email)
-
return [] unless table_exists?('slick_form_submissions')
-
ActiveRecord::Base.connection.execute(
-
"SELECT * FROM slick_form_submissions WHERE JSON_EXTRACT(data, '$.email') = '#{email}'"
-
).to_a
-
end
-
-
def add_honeypot_field(fields, form)
-
# Add honeypot field for spam protection
-
fields + [{ type: 'text', name: 'website', label: 'Website', hidden: true }]
-
end
-
-
def create_forms_table
-
return if table_exists?('slick_forms')
-
-
ActiveRecord::Migration.create_table :slick_forms do |t|
-
t.string :name, null: false
-
t.string :title
-
t.text :description
-
t.json :fields, default: []
-
t.json :settings, default: {}
-
t.boolean :active, default: true
-
t.integer :submissions_count, default: 0
-
t.integer :tenant_id
-
t.timestamps
-
end
-
-
log("Created slick_forms table")
-
end
-
-
def create_submissions_table
-
return if table_exists?('slick_form_submissions')
-
-
ActiveRecord::Migration.create_table :slick_form_submissions do |t|
-
t.references :slick_form, null: false
-
t.json :data
-
t.string :ip_address
-
t.string :user_agent
-
t.string :referrer
-
t.boolean :spam, default: false
-
t.integer :tenant_id
-
t.timestamps
-
end
-
-
log("Created slick_form_submissions table")
-
end
-
-
def drop_tables
-
ActiveRecord::Migration.drop_table :slick_form_submissions if table_exists?('slick_form_submissions')
-
ActiveRecord::Migration.drop_table :slick_forms if table_exists?('slick_forms')
-
log("Dropped SlickForms tables")
-
end
-
-
def table_exists?(table_name)
-
ActiveRecord::Base.connection.table_exists?(table_name)
-
end
-
-
def get_all_forms
-
return [] unless table_exists?('slick_forms')
-
ActiveRecord::Base.connection.execute("SELECT * FROM slick_forms").to_a.map(&:symbolize_keys)
-
end
-
-
def get_submission_count
-
return 0 unless table_exists?('slick_form_submissions')
-
ActiveRecord::Base.connection.execute("SELECT COUNT(*) as count FROM slick_form_submissions WHERE spam = 0").first['count']
-
end
-
-
def get_recent_submissions(limit = 50)
-
return [] unless table_exists?('slick_form_submissions')
-
ActiveRecord::Base.connection.execute(
-
"SELECT * FROM slick_form_submissions WHERE spam = 0 ORDER BY created_at DESC LIMIT #{limit}"
-
).to_a.map(&:symbolize_keys)
-
end
-
-
def get_submissions_today_count
-
return 0 unless table_exists?('slick_form_submissions')
-
today = Date.today.to_s
-
ActiveRecord::Base.connection.execute(
-
"SELECT COUNT(*) as count FROM slick_form_submissions WHERE DATE(created_at) = '#{today}' AND spam = 0"
-
).first['count']
-
end
-
-
def get_submissions_week_count
-
return 0 unless table_exists?('slick_form_submissions')
-
week_ago = 7.days.ago.to_s
-
ActiveRecord::Base.connection.execute(
-
"SELECT COUNT(*) as count FROM slick_form_submissions WHERE created_at >= '#{week_ago}' AND spam = 0"
-
).first['count']
-
end
-
end
-
-
# Register the plugin
-
Railspress::PluginSystem.register_plugin('slick_forms', SlickForms.new)
-
-
# SlickForms Pro - Advanced Form Builder
-
#
-
# Extends SlickForms with premium features:
-
# - Advanced field types (file upload, date picker, rating, signature)
-
# - Conditional logic
-
# - Multi-page forms
-
# - Payment integration (Stripe)
-
# - Advanced analytics
-
# - White labeling
-
# - Priority support
-
-
class SlickFormsPro < Railspress::PluginBase
-
plugin_name 'Slick Forms Pro'
-
plugin_version '2.0.0'
-
plugin_description 'Advanced form builder with premium features, payments, and analytics'
-
plugin_author 'RailsPress Pro'
-
plugin_url 'https://railspress.com/plugins/slick-forms-pro'
-
plugin_license 'Commercial'
-
-
def setup
-
# Check if base SlickForms is active
-
unless plugin_active?('slick_forms')
-
log("WARNING: SlickForms Pro requires SlickForms base plugin", :warn)
-
end
-
-
# Additional Pro Settings
-
define_setting :enable_payments,
-
type: 'boolean',
-
label: 'Enable Payment Forms',
-
description: 'Allow forms to accept payments via Stripe',
-
default: false
-
-
define_setting :stripe_publishable_key,
-
type: 'string',
-
label: 'Stripe Publishable Key',
-
placeholder: 'pk_...'
-
-
define_setting :stripe_secret_key,
-
type: 'string',
-
label: 'Stripe Secret Key',
-
placeholder: 'sk_...'
-
-
define_setting :enable_analytics,
-
type: 'boolean',
-
label: 'Enable Analytics',
-
description: 'Track form views, submissions, and conversion rates',
-
default: true
-
-
define_setting :enable_conditional_logic,
-
type: 'boolean',
-
label: 'Enable Conditional Logic',
-
description: 'Show/hide fields based on other field values',
-
default: true
-
-
define_setting :max_file_size,
-
type: 'number',
-
label: 'Max File Size (MB)',
-
default: 10,
-
min: 1,
-
max: 100
-
-
define_setting :allowed_file_types,
-
type: 'text',
-
label: 'Allowed File Types',
-
description: 'Comma-separated list of allowed extensions',
-
default: 'pdf,doc,docx,jpg,png'
-
-
# Register admin pages
-
register_admin_page(
-
slug: 'analytics',
-
title: 'Form Analytics',
-
menu_title: 'Analytics',
-
icon: 'chart',
-
callback: :render_analytics_page
-
)
-
-
register_admin_page(
-
slug: 'payments',
-
title: 'Payment Forms',
-
menu_title: 'Payments',
-
icon: 'credit-card',
-
callback: :render_payments_page
-
)
-
-
register_admin_page(
-
slug: 'integrations',
-
title: 'Integrations',
-
menu_title: 'Integrations',
-
icon: 'link',
-
callback: :render_integrations_page
-
)
-
-
register_admin_page(
-
slug: 'settings',
-
title: 'Pro Settings',
-
menu_title: 'Pro Settings',
-
icon: 'cog'
-
)
-
-
# Register admin routes (automatically scoped under /admin)
-
register_admin_routes do
-
namespace :slick_forms_pro do
-
# Analytics
-
get 'analytics/overview', to: 'analytics#overview'
-
get 'analytics/form/:id', to: 'analytics#form'
-
get 'analytics/export', to: 'analytics#export'
-
-
# Payments
-
resources :payments, only: [:index, :show] do
-
member do
-
post :refund
-
end
-
end
-
-
# Integrations
-
resources :integrations do
-
member do
-
post :test
-
patch :toggle
-
end
-
end
-
-
# Templates
-
resources :templates, only: [:index, :show, :create]
-
end
-
end
-
-
# Register frontend routes (automatically scoped under /plugins)
-
register_frontend_routes do
-
# File upload endpoint
-
post 'upload', to: 'slick_forms_pro/uploads#create', as: 'slick_form_pro_upload'
-
-
# Payment webhook
-
post 'webhooks/stripe', to: 'slick_forms_pro/webhooks#stripe'
-
-
# API endpoints
-
namespace :api do
-
namespace :v1 do
-
get 'forms/:id/stats', to: 'slick_forms_pro/stats#show'
-
post 'forms/:id/validate', to: 'slick_forms_pro/validator#validate'
-
end
-
end
-
end
-
-
# Create background jobs
-
create_job 'SlickFormsProAnalyticsJob' do
-
def perform
-
# Process analytics data
-
Rails.logger.info "Processing SlickForms Pro analytics"
-
end
-
end
-
-
create_job 'SlickFormsProPaymentJob' do
-
def perform(payment_id)
-
# Process payment
-
Rails.logger.info "Processing payment ##{payment_id}"
-
end
-
end
-
-
# Schedule recurring analytics job
-
schedule_recurring_job(
-
'analytics_daily',
-
'SlickFormsProAnalyticsJob',
-
cron: '0 2 * * *' # Daily at 2 AM
-
)
-
-
# Add filters to extend base plugin
-
add_filter('slick_forms_field_types', :add_pro_fields)
-
add_filter('slick_forms_form_settings', :add_pro_settings)
-
add_action('slick_forms_submission_saved', :process_pro_features)
-
-
log("SlickForms Pro initialized successfully")
-
end
-
-
def activate
-
super
-
create_pro_tables
-
set_setting(:enable_analytics, true)
-
set_setting(:max_file_size, 10)
-
log("SlickForms Pro activated")
-
end
-
-
# Render analytics page
-
def render_analytics_page
-
{
-
title: 'Form Analytics',
-
charts: generate_analytics_charts,
-
stats: {
-
total_views: get_total_views,
-
total_submissions: get_total_submissions,
-
conversion_rate: calculate_conversion_rate,
-
average_time: calculate_average_completion_time
-
},
-
top_forms: get_top_performing_forms(5)
-
}
-
end
-
-
# Render payments page
-
def render_payments_page
-
{
-
title: 'Payment Forms',
-
payments: get_recent_payments(50),
-
stats: {
-
total_revenue: calculate_total_revenue,
-
successful_payments: get_successful_payments_count,
-
failed_payments: get_failed_payments_count,
-
refunded_amount: calculate_refunded_amount
-
}
-
}
-
end
-
-
# Render integrations page
-
def render_integrations_page
-
{
-
title: 'Integrations',
-
available_integrations: available_integrations,
-
active_integrations: get_active_integrations
-
}
-
end
-
-
# Available integrations
-
def available_integrations
-
[
-
{ id: 'mailchimp', name: 'Mailchimp', icon: '📧', description: 'Add subscribers to Mailchimp lists' },
-
{ id: 'slack', name: 'Slack', icon: '💬', description: 'Send notifications to Slack channels' },
-
{ id: 'zapier', name: 'Zapier', icon: '⚡', description: 'Connect to 3000+ apps via Zapier' },
-
{ id: 'google_sheets', name: 'Google Sheets', icon: '📊', description: 'Save submissions to Google Sheets' },
-
{ id: 'webhooks', name: 'Custom Webhooks', icon: '🔗', description: 'Send data to custom webhook URLs' }
-
]
-
end
-
-
private
-
-
def plugin_active?(plugin_identifier)
-
Railspress::PluginSystem.plugin_loaded?(plugin_identifier)
-
end
-
-
def add_pro_fields(base_fields, form)
-
base_fields + [
-
{ type: 'file', label: 'File Upload', icon: '📎' },
-
{ type: 'date', label: 'Date Picker', icon: '📅' },
-
{ type: 'time', label: 'Time Picker', icon: '⏰' },
-
{ type: 'rating', label: 'Star Rating', icon: '⭐' },
-
{ type: 'signature', label: 'Signature Pad', icon: '✍️' },
-
{ type: 'phone', label: 'Phone Number', icon: '📱' },
-
{ type: 'address', label: 'Address Field', icon: '🏠' },
-
{ type: 'payment', label: 'Payment Field', icon: '💳' },
-
{ type: 'slider', label: 'Range Slider', icon: '🎚️' },
-
{ type: 'color', label: 'Color Picker', icon: '🎨' },
-
{ type: 'matrix', label: 'Matrix Rating', icon: '📊' },
-
{ type: 'survey', label: 'Survey Field', icon: '📋' }
-
]
-
end
-
-
# Pro version features (extends free features)
-
def features
-
{
-
'Drag and Drop Builder' => true,
-
'AI Form Builder' => true,
-
'Conditional Logic' => true,
-
'Advanced Form Styler' => true,
-
'Numeric Calculation' => true,
-
'Unique Entry Validation' => true,
-
'Multi-Step Forms' => true,
-
'Conversational Forms' => true,
-
'Advanced Post Creation' => true,
-
'Payment' => true,
-
'Coupon' => true,
-
'Inventory' => true,
-
'Address Autocomplete' => true,
-
'Spam Protection' => true,
-
'Quiz and Survey' => true,
-
'Multi-column Form' => true,
-
'Version History' => true,
-
'Fully Responsive' => true,
-
'Personality Quiz' => true,
-
'CSS Ready Classes' => true,
-
'Keyboard Navigation' => true,
-
'Undo/Redo' => true,
-
'Default Input Fields Value' => true,
-
'Accessibility' => true
-
}
-
end
-
-
# Implement Pro Features
-
-
def advanced_form_styler_enabled?
-
true # Advanced CSS customization, themes
-
end
-
-
def numeric_calculation_enabled?
-
true # Mathematical operations between fields
-
end
-
-
def multi_step_forms_enabled?
-
true # Wizard-style multi-page forms
-
end
-
-
def advanced_post_creation_enabled?
-
true # Auto-create posts from form submissions
-
end
-
-
def coupon_enabled?
-
true # Discount codes, coupons
-
end
-
-
def inventory_enabled?
-
true # Stock management, product selection
-
end
-
-
def address_autocomplete_enabled?
-
true # Google Places API integration
-
end
-
-
def quiz_survey_enabled?
-
true # Scoring, results, analytics
-
end
-
-
def personality_quiz_enabled?
-
true # Advanced quiz logic, personality tests
-
end
-
-
# Advanced field types
-
def advanced_field_types
-
[
-
{ type: 'calculation', label: 'Calculation Field', icon: '🧮' },
-
{ type: 'signature', label: 'Signature Pad', icon: '✍️' },
-
{ type: 'file_upload', label: 'File Upload', icon: '📎' },
-
{ type: 'date_time', label: 'Date & Time', icon: '📅' },
-
{ type: 'rating', label: 'Rating Scale', icon: '⭐' },
-
{ type: 'matrix', label: 'Matrix Rating', icon: '📊' },
-
{ type: 'slider', label: 'Range Slider', icon: '🎚️' },
-
{ type: 'color_picker', label: 'Color Picker', icon: '🎨' },
-
{ type: 'address', label: 'Address with Autocomplete', icon: '🏠' },
-
{ type: 'product_selector', label: 'Product Selector', icon: '🛒' },
-
{ type: 'coupon_field', label: 'Coupon Code', icon: '🎫' },
-
{ type: 'inventory_tracker', label: 'Inventory Tracker', icon: '📦' }
-
]
-
end
-
-
# Process advanced features
-
def process_pro_features(submission)
-
# Process calculations
-
if numeric_calculation_enabled?
-
process_calculations(submission)
-
end
-
-
# Process inventory
-
if inventory_enabled?
-
update_inventory(submission)
-
end
-
-
# Process coupons
-
if coupon_enabled?
-
validate_coupon(submission)
-
end
-
-
# Process quiz scoring
-
if quiz_survey_enabled?
-
calculate_quiz_score(submission)
-
end
-
-
# Process address autocomplete
-
if address_autocomplete_enabled?
-
validate_address(submission)
-
end
-
-
# Process advanced post creation
-
if advanced_post_creation_enabled?
-
create_advanced_post(submission)
-
end
-
end
-
-
private
-
-
def process_calculations(submission)
-
log("Processing numeric calculations")
-
# Calculate totals, tax, shipping, etc.
-
end
-
-
def update_inventory(submission)
-
log("Updating inventory levels")
-
# Decrease stock for purchased items
-
end
-
-
def validate_coupon(submission)
-
log("Validating coupon code")
-
# Check coupon validity and apply discount
-
end
-
-
def calculate_quiz_score(submission)
-
log("Calculating quiz score")
-
# Score quiz responses and determine results
-
end
-
-
def validate_address(submission)
-
log("Validating and formatting address")
-
# Use Google Places API to validate addresses
-
end
-
-
def create_advanced_post(submission)
-
log("Creating advanced post from submission")
-
# Auto-generate posts with custom fields, categories, etc.
-
end
-
-
def add_pro_settings(base_settings, form)
-
base_settings.merge({
-
conditional_logic: true,
-
multi_page: true,
-
save_progress: true,
-
payment_enabled: get_setting(:enable_payments, false)
-
})
-
end
-
-
def process_pro_features(submission)
-
# Process analytics
-
track_submission_analytics(submission) if get_setting(:enable_analytics, true)
-
-
# Process payment if applicable
-
process_payment(submission) if submission[:payment_required]
-
-
# Send to integrations
-
send_to_integrations(submission)
-
end
-
-
def create_pro_tables
-
# Analytics table
-
unless table_exists?('slick_forms_analytics')
-
ActiveRecord::Migration.create_table :slick_forms_analytics do |t|
-
t.references :slick_form, null: false
-
t.date :date, null: false
-
t.integer :views, default: 0
-
t.integer :submissions, default: 0
-
t.integer :spam_blocked, default: 0
-
t.decimal :conversion_rate, precision: 5, scale: 2
-
t.decimal :avg_completion_time, precision: 10, scale: 2
-
t.timestamps
-
end
-
end
-
-
# Payments table
-
unless table_exists?('slick_forms_payments')
-
ActiveRecord::Migration.create_table :slick_forms_payments do |t|
-
t.references :slick_form_submission, null: false
-
t.string :stripe_payment_intent_id
-
t.decimal :amount, precision: 10, scale: 2, null: false
-
t.string :currency, default: 'usd'
-
t.string :status # pending, succeeded, failed, refunded
-
t.text :error_message
-
t.datetime :paid_at
-
t.datetime :refunded_at
-
t.timestamps
-
end
-
end
-
-
# Integrations table
-
unless table_exists?('slick_forms_integrations')
-
ActiveRecord::Migration.create_table :slick_forms_integrations do |t|
-
t.references :slick_form, null: false
-
t.string :integration_type # mailchimp, slack, zapier, etc.
-
t.string :name
-
t.json :config, default: {}
-
t.boolean :active, default: true
-
t.timestamps
-
end
-
end
-
-
log("Created SlickForms Pro tables")
-
end
-
-
def track_submission_analytics(submission)
-
# Analytics tracking logic
-
end
-
-
def process_payment(submission)
-
# Payment processing logic
-
end
-
-
def send_to_integrations(submission)
-
# Integration sending logic
-
end
-
-
def generate_analytics_charts
-
# Chart data generation
-
[]
-
end
-
-
def get_total_views
-
0
-
end
-
-
def get_total_submissions
-
0
-
end
-
-
def calculate_conversion_rate
-
0.0
-
end
-
-
def calculate_average_completion_time
-
0.0
-
end
-
-
def get_top_performing_forms(limit)
-
[]
-
end
-
-
def get_recent_payments(limit)
-
[]
-
end
-
-
def calculate_total_revenue
-
0.0
-
end
-
-
def get_successful_payments_count
-
0
-
end
-
-
def get_failed_payments_count
-
0
-
end
-
-
def calculate_refunded_amount
-
0.0
-
end
-
-
def get_active_integrations
-
[]
-
end
-
-
def table_exists?(table_name)
-
ActiveRecord::Base.connection.table_exists?(table_name)
-
end
-
end
-
-
# Register the plugin
-
Railspress::PluginSystem.register_plugin('slick_forms_pro', SlickFormsPro.new)
-
# Social Sharing Plugin
-
# Adds social media sharing buttons to posts and pages
-
-
class SocialSharing < Railspress::PluginBase
-
plugin_name 'Social Sharing'
-
plugin_version '1.0.0'
-
plugin_description 'Add beautiful social sharing buttons to your content'
-
plugin_author 'RailsPress'
-
-
def activate
-
super
-
inject_helper_methods
-
end
-
-
private
-
-
def inject_helper_methods
-
ApplicationController.helper_method :social_share_buttons if defined?(ApplicationController)
-
end
-
-
# Generate Open Graph meta tags
-
def self.open_graph_tags(post)
-
return '' unless post
-
-
tags = []
-
tags << tag(:meta, property: 'og:title', content: post.title)
-
tags << tag(:meta, property: 'og:type', content: 'article')
-
tags << tag(:meta, property: 'og:url', content: post_url(post))
-
tags << tag(:meta, property: 'og:description', content: post.excerpt || post.title)
-
-
if post.featured_image_file.attached?
-
tags << tag(:meta, property: 'og:image', content: url_for(post.featured_image_file))
-
end
-
-
tags << tag(:meta, property: 'article:published_time', content: post.published_at.iso8601)
-
tags << tag(:meta, property: 'article:author', content: post.author_name)
-
-
tags.join("\n").html_safe
-
end
-
-
# Generate Twitter Card meta tags
-
def self.twitter_card_tags(post)
-
return '' unless post
-
-
tags = []
-
tags << tag(:meta, name: 'twitter:card', content: 'summary_large_image')
-
tags << tag(:meta, name: 'twitter:title', content: post.title)
-
tags << tag(:meta, name: 'twitter:description', content: post.excerpt || post.title)
-
-
if post.featured_image_file.attached?
-
tags << tag(:meta, name: 'twitter:image', content: url_for(post.featured_image_file))
-
end
-
-
tags.join("\n").html_safe
-
end
-
-
def self.tag(name, attributes = {})
-
attrs = attributes.map { |k, v| "#{k}=\"#{ERB::Util.html_escape(v)}\"" }.join(' ')
-
"<#{name} #{attrs}>"
-
end
-
-
def self.post_url(post)
-
# This would use the actual URL helper
-
"http://localhost:3000/blog/#{post.slug}"
-
end
-
-
def self.url_for(attachment)
-
Rails.application.routes.url_helpers.rails_blob_url(attachment, only_path: false)
-
rescue
-
''
-
end
-
end
-
-
# Helper module
-
module SocialSharingHelper
-
def social_share_buttons(post, options = {})
-
platforms = options[:platforms] || [:facebook, :twitter, :linkedin, :pinterest, :email]
-
size = options[:size] || 'medium'
-
-
url = blog_post_url(post.slug)
-
title = post.title
-
-
buttons = platforms.map do |platform|
-
case platform
-
when :facebook
-
link_to "https://www.facebook.com/sharer/sharer.php?u=#{CGI.escape(url)}",
-
target: '_blank',
-
class: "share-button share-facebook #{size}",
-
title: "Share on Facebook" do
-
'<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'.html_safe
-
end
-
when :twitter
-
link_to "https://twitter.com/intent/tweet?url=#{CGI.escape(url)}&text=#{CGI.escape(title)}",
-
target: '_blank',
-
class: "share-button share-twitter #{size}",
-
title: "Share on Twitter" do
-
'<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>'.html_safe
-
end
-
when :linkedin
-
link_to "https://www.linkedin.com/shareArticle?mini=true&url=#{CGI.escape(url)}&title=#{CGI.escape(title)}",
-
target: '_blank',
-
class: "share-button share-linkedin #{size}",
-
title: "Share on LinkedIn" do
-
'<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'.html_safe
-
end
-
when :email
-
link_to "mailto:?subject=#{CGI.escape(title)}&body=#{CGI.escape(url)}",
-
class: "share-button share-email #{size}",
-
title: "Share via Email" do
-
'<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>'.html_safe
-
end
-
end
-
end
-
-
content_tag :div, class: 'social-share-buttons flex items-center space-x-2' do
-
buttons.join.html_safe
-
end
-
end
-
-
def social_meta_tags(post)
-
return '' unless post
-
(SocialSharing.open_graph_tags(post) + "\n" + SocialSharing.twitter_card_tags(post)).html_safe
-
end
-
end
-
-
# Include helper
-
if defined?(ApplicationController)
-
ApplicationController.helper(SocialSharingHelper)
-
end
-
-
module SocialSharingHelper
-
include ActionView::Helpers::UrlHelper
-
include ActionView::Helpers::TagHelper
-
include ActionView::Context
-
-
def social_share_buttons(post, options = {})
-
return '' unless post
-
-
platforms = options[:platforms] || [:facebook, :twitter, :linkedin, :email]
-
-
url = blog_post_url(post.slug) rescue "#"
-
title = post.title
-
-
buttons_html = platforms.map do |platform|
-
share_button_for(platform, url, title)
-
end.join
-
-
content_tag :div, buttons_html.html_safe, class: 'flex items-center space-x-2'
-
end
-
-
def social_meta_tags(post)
-
SocialSharing.open_graph_tags(post) + SocialSharing.twitter_card_tags(post)
-
end
-
-
private
-
-
def share_button_for(platform, url, title)
-
case platform
-
when :facebook
-
share_url = "https://www.facebook.com/sharer/sharer.php?u=#{CGI.escape(url)}"
-
icon_svg = '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>'
-
bg_class = 'bg-blue-600 hover:bg-blue-700'
-
when :twitter
-
share_url = "https://twitter.com/intent/tweet?url=#{CGI.escape(url)}&text=#{CGI.escape(title)}"
-
icon_svg = '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/></svg>'
-
bg_class = 'bg-sky-500 hover:bg-sky-600'
-
when :linkedin
-
share_url = "https://www.linkedin.com/shareArticle?mini=true&url=#{CGI.escape(url)}&title=#{CGI.escape(title)}"
-
icon_svg = '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>'
-
bg_class = 'bg-blue-700 hover:bg-blue-800'
-
when :email
-
share_url = "mailto:?subject=#{CGI.escape(title)}&body=#{CGI.escape(url)}"
-
icon_svg = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>'
-
bg_class = 'bg-gray-600 hover:bg-gray-700'
-
else
-
return ''
-
end
-
-
link_to share_url,
-
target: '_blank',
-
rel: 'noopener noreferrer',
-
class: "p-2 #{bg_class} text-white rounded-lg transition",
-
title: "Share on #{platform.to_s.titleize}" do
-
icon_svg.html_safe
-
end
-
end
-
end
-
-
SocialSharing.new
-
-
-
-
-
-
-
-
-
# Spam Protection Plugin
-
# Protects against comment spam using various techniques
-
-
class SpamProtection < Railspress::PluginBase
-
plugin_name 'Spam Protection'
-
plugin_version '1.0.0'
-
plugin_description 'Advanced spam protection for comments'
-
plugin_author 'RailsPress'
-
-
# Common spam keywords
-
SPAM_KEYWORDS = %w[viagra cialis casino poker gambling loan mortgage]
-
-
# Suspicious patterns
-
SUSPICIOUS_PATTERNS = [
-
/\b\d{10,}\b/, # Long numbers (phone/credit card)
-
/<a\s+href/i, # HTML links
-
/http.*http/i, # Multiple URLs
-
/\[url=/i # BBCode links
-
]
-
-
def activate
-
super
-
register_hooks
-
end
-
-
private
-
-
def register_hooks
-
# Filter comments before creation
-
add_filter('comment_before_save', :check_for_spam)
-
-
# Action after comment is flagged
-
add_action('comment_marked_spam', :log_spam_attempt)
-
end
-
-
# Check if comment is spam
-
def check_for_spam(comment)
-
return comment unless comment.new_record?
-
-
spam_score = calculate_spam_score(comment)
-
-
if spam_score >= get_setting('spam_threshold', 3)
-
comment.status = :spam
-
Railspress::PluginSystem.do_action('comment_marked_spam', comment)
-
end
-
-
comment
-
end
-
-
def calculate_spam_score(comment)
-
score = 0
-
content = comment.content.to_s.downcase
-
-
# Check for spam keywords
-
SPAM_KEYWORDS.each do |keyword|
-
score += 1 if content.include?(keyword)
-
end
-
-
# Check for suspicious patterns
-
SUSPICIOUS_PATTERNS.each do |pattern|
-
score += 1 if content.match?(pattern)
-
end
-
-
# Check for excessive links
-
link_count = content.scan(/https?:\/\//).count
-
score += 2 if link_count > 3
-
-
# Check for ALL CAPS
-
if content.length > 20 && content.upcase == content
-
score += 1
-
end
-
-
# Check for repeated characters
-
score += 1 if content.match?(/(.)\1{5,}/)
-
-
# Very short comments with links are suspicious
-
if content.length < 20 && link_count > 0
-
score += 2
-
end
-
-
score
-
end
-
-
def log_spam_attempt(comment)
-
Rails.logger.warn "Spam detected: #{comment.author_email} - Score: #{calculate_spam_score(comment)}"
-
end
-
-
# Public method to check if comment is likely spam
-
def self.is_spam?(comment)
-
plugin = new
-
plugin.calculate_spam_score(comment) >= plugin.get_setting('spam_threshold', 3)
-
end
-
end
-
-
# Extend Comment model
-
if defined?(Comment)
-
Comment.class_eval do
-
before_validation :apply_spam_protection, on: :create
-
-
private
-
-
def apply_spam_protection
-
if Railspress::PluginSystem.plugin_loaded?('Spam Protection')
-
filtered = Railspress::PluginSystem.apply_filters('comment_before_save', self)
-
end
-
end
-
end
-
end
-
-
SpamProtection.new
-
-
-
-
-
-
-
-
-
class Uploadcare < Railspress::PluginBase
-
plugin_name 'Uploadcare'
-
plugin_version '1.0.0'
-
plugin_description 'Professional media management and CDN with Uploadcare'
-
plugin_author 'RailsPress Team'
-
-
# Define comprehensive settings schema
-
settings_schema do
-
section 'API Configuration', description: 'Your Uploadcare API credentials' do
-
text 'public_key', 'Public Key',
-
description: 'Your Uploadcare public key (starts with your project ID)',
-
required: true,
-
placeholder: 'demopublickey',
-
pattern: /\A[a-zA-Z0-9]+\z/
-
-
text 'secret_key', 'Secret Key',
-
description: 'Your Uploadcare secret key (keep this private!)',
-
required: false,
-
placeholder: 'demoprivatekey'
-
end
-
-
section 'Upload Widget', description: 'Configure the Uploadcare upload widget' do
-
checkbox 'enable_widget', 'Enable Upload Widget',
-
description: 'Show Uploadcare widget in admin media section',
-
default: true
-
-
select 'widget_theme', 'Widget Theme',
-
[
-
['Light', 'light'],
-
['Dark', 'dark'],
-
['Minimal', 'minimal']
-
],
-
description: 'Visual theme for the upload widget',
-
default: 'light'
-
-
checkbox 'multiple_files', 'Multiple File Upload',
-
description: 'Allow uploading multiple files at once',
-
default: true
-
-
number 'max_file_size', 'Max File Size (MB)',
-
description: 'Maximum file size for uploads',
-
default: 25,
-
min: 1,
-
max: 100
-
end
-
-
section 'File Sources', description: 'Choose where users can upload files from' do
-
checkbox 'source_local', 'Local Files',
-
description: 'Upload from computer',
-
default: true
-
-
checkbox 'source_url', 'From URL',
-
description: 'Import from URL',
-
default: true
-
-
checkbox 'source_camera', 'Camera',
-
description: 'Capture from camera/webcam',
-
default: true
-
-
checkbox 'source_dropbox', 'Dropbox',
-
description: 'Import from Dropbox',
-
default: false
-
-
checkbox 'source_gdrive', 'Google Drive',
-
description: 'Import from Google Drive',
-
default: false
-
-
checkbox 'source_instagram', 'Instagram',
-
description: 'Import from Instagram',
-
default: false
-
-
checkbox 'source_facebook', 'Facebook',
-
description: 'Import from Facebook',
-
default: false
-
end
-
-
section 'Image Processing', description: 'Automatic image transformations' do
-
checkbox 'auto_crop', 'Auto Crop',
-
description: 'Automatically crop images to focus area',
-
default: false
-
-
checkbox 'auto_rotate', 'Auto Rotate',
-
description: 'Automatically rotate images based on EXIF',
-
default: true
-
-
select 'image_quality', 'Image Quality',
-
[
-
['Normal', 'normal'],
-
['Better', 'better'],
-
['Best', 'best'],
-
['Lighter', 'lighter']
-
],
-
description: 'Balance between quality and file size',
-
default: 'normal'
-
-
checkbox 'progressive_jpeg', 'Progressive JPEG',
-
description: 'Convert JPEGs to progressive format',
-
default: true
-
-
checkbox 'strip_metadata', 'Strip Metadata',
-
description: 'Remove EXIF data for privacy/smaller files',
-
default: false
-
end
-
-
section 'CDN & Performance', description: 'Content delivery optimization' do
-
checkbox 'use_cdn', 'Enable CDN',
-
description: 'Serve files through Uploadcare CDN',
-
default: true
-
-
checkbox 'lazy_loading', 'Lazy Loading',
-
description: 'Load images only when visible',
-
default: true
-
-
checkbox 'responsive_images', 'Responsive Images',
-
description: 'Generate multiple sizes for different screens',
-
default: true
-
-
text 'cdn_base', 'Custom CDN Domain',
-
description: 'Custom CNAME for CDN (leave blank for default)',
-
placeholder: 'cdn.example.com'
-
end
-
-
section 'Dashboard', description: 'Uploadcare dashboard integration' do
-
checkbox 'show_dashboard', 'Show Dashboard',
-
description: 'Embed Uploadcare dashboard in admin',
-
default: true
-
-
radio 'dashboard_view', 'Default View',
-
[
-
['Files', 'files'],
-
['Gallery', 'gallery'],
-
['Analytics', 'analytics']
-
],
-
description: 'Default view when opening dashboard',
-
default: 'files'
-
end
-
-
section 'Advanced', description: 'Advanced configuration options' do
-
number 'retry_count', 'Upload Retry Count',
-
description: 'Number of retry attempts for failed uploads',
-
default: 3,
-
min: 0,
-
max: 10
-
-
checkbox 'store_files', 'Store Files Permanently',
-
description: 'Store files instead of deleting after 24 hours',
-
default: true
-
-
checkbox 'secure_signature', 'Secure Upload Signature',
-
description: 'Require signed uploads (more secure)',
-
default: false
-
-
code 'custom_css', 'Custom Widget CSS',
-
description: 'Custom CSS for the upload widget',
-
language: 'css',
-
placeholder: '.uploadcare-widget { border-radius: 8px; }'
-
end
-
end
-
-
def initialize
-
super
-
setup_uploadcare if enabled?
-
end
-
-
def activate
-
super
-
Rails.logger.info "Uploadcare plugin activated"
-
validate_api_credentials
-
end
-
-
def enabled?
-
get_setting('enable_widget', true) &&
-
get_setting('public_key').present?
-
end
-
-
# Get widget configuration
-
def widget_config
-
sources = []
-
sources << 'local' if get_setting('source_local', true)
-
sources << 'url' if get_setting('source_url', true)
-
sources << 'camera' if get_setting('source_camera', true)
-
sources << 'dropbox' if get_setting('source_dropbox', false)
-
sources << 'gdrive' if get_setting('source_gdrive', false)
-
sources << 'instagram' if get_setting('source_instagram', false)
-
sources << 'facebook' if get_setting('source_facebook', false)
-
-
{
-
publicKey: get_setting('public_key'),
-
multiple: get_setting('multiple_files', true),
-
imagesOnly: false,
-
previewStep: true,
-
imageShrink: get_setting('responsive_images', true) ? '1024x1024' : false,
-
multipleMax: get_setting('multiple_files', true) ? 10 : 1,
-
tabs: sources.join(' '),
-
systemDialog: false,
-
locale: 'en',
-
theme: get_setting('widget_theme', 'light'),
-
crop: get_setting('auto_crop', false) ? 'free' : false
-
}
-
end
-
-
# Get CDN URL for file
-
def cdn_url(uuid, transformations = {})
-
base = get_setting('cdn_base').presence || 'https://ucarecdn.com'
-
url = "#{base}/#{uuid}/"
-
-
if transformations.any?
-
operations = []
-
operations << "quality/#{transformations[:quality]}" if transformations[:quality]
-
operations << "resize/#{transformations[:width]}x#{transformations[:height]}" if transformations[:width]
-
operations << "crop/#{transformations[:crop]}" if transformations[:crop]
-
operations << 'progressive/yes' if get_setting('progressive_jpeg', true)
-
operations << 'autorotate/yes' if get_setting('auto_rotate', true)
-
-
url += "#{operations.join('/')}/" if operations.any?
-
end
-
-
url
-
end
-
-
# Dashboard URL
-
def dashboard_url
-
project_id = get_setting('public_key')&.split('_')&.first
-
return nil unless project_id
-
-
view = get_setting('dashboard_view', 'files')
-
"https://uploadcare.com/dashboard/#{project_id}/#{view}/"
-
end
-
-
private
-
-
def setup_uploadcare
-
# Register filters to inject Uploadcare widget
-
add_filter('admin_head', 10) do |content|
-
content + uploadcare_widget_script
-
end
-
-
# Register action to process uploaded files
-
add_action('media_uploaded', 20) do |media|
-
process_uploadcare_file(media)
-
end
-
end
-
-
def uploadcare_widget_script
-
return '' unless enabled?
-
-
config = widget_config.to_json
-
-
<<~HTML
-
<!-- Uploadcare Widget -->
-
<script>
-
UPLOADCARE_PUBLIC_KEY = '#{get_setting('public_key')}';
-
UPLOADCARE_TABS = '#{widget_config[:tabs]}';
-
UPLOADCARE_LOCALE = 'en';
-
</script>
-
<script src="https://ucarecdn.com/libs/widget/3.x/uploadcare.full.min.js"></script>
-
<link rel="stylesheet" href="https://ucarecdn.com/libs/widget/3.x/uploadcare.min.css" />
-
-
#{custom_widget_css}
-
HTML
-
end
-
-
def custom_widget_css
-
css = get_setting('custom_css')
-
return '' if css.blank?
-
-
<<~HTML
-
<style>
-
#{css}
-
</style>
-
HTML
-
end
-
-
def process_uploadcare_file(media)
-
# Store file permanently if setting is enabled
-
if get_setting('store_files', true)
-
store_file(media.uploadcare_uuid)
-
end
-
-
# Apply transformations
-
if media.image? && get_setting('responsive_images', true)
-
generate_responsive_versions(media)
-
end
-
end
-
-
def store_file(uuid)
-
# Call Uploadcare API to store file permanently
-
return unless get_setting('secret_key').present?
-
-
Rails.logger.info "Storing Uploadcare file: #{uuid}"
-
# TODO: Implement actual API call
-
end
-
-
def generate_responsive_versions(media)
-
# Generate responsive image versions
-
Rails.logger.info "Generating responsive versions for: #{media.id}"
-
# Handled by Uploadcare CDN on-the-fly
-
end
-
-
def validate_api_credentials
-
public_key = get_setting('public_key')
-
-
if public_key.blank?
-
Rails.logger.warn "Uploadcare: No public key configured"
-
return false
-
end
-
-
# TODO: Test API connection
-
Rails.logger.info "Uploadcare: API credentials configured"
-
true
-
end
-
end
-
-
# Auto-initialize if active
-
if Plugin.exists?(name: 'Uploadcare', active: true)
-
Uploadcare.new
-
end
-
-
-
-
-
-
-
-
-
module Railspress
-
module AiAgentIntegration
-
# AI Agent integration for channels and content optimization
-
module Channels
-
extend ActiveSupport::Concern
-
-
# Generate channel-specific content using AI
-
def self.generate_content_for_channel(content, channel_slug, ai_agent_name = nil)
-
channel = Railspress::PluginApi::Channels.find_channel(channel_slug)
-
return content unless channel
-
-
# Get AI agent for content generation
-
agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'content_optimizer')
-
return content unless agent&.active?
-
-
# Get channel-specific settings
-
settings = Railspress::PluginApi::Channels.channel_settings(channel_slug)
-
-
# Create channel-aware prompt
-
prompt = build_channel_prompt(content, channel, settings, agent)
-
-
# Generate optimized content
-
begin
-
response = agent.generate_response(prompt)
-
response.present? ? response : content
-
rescue => e
-
Rails.logger.error "AI content generation failed: #{e.message}"
-
content
-
end
-
end
-
-
# Optimize content for multiple channels
-
def self.optimize_content_for_all_channels(content, ai_agent_name = nil)
-
optimized_content = {}
-
-
Channel.active.each do |channel|
-
optimized_content[channel.slug] = generate_content_for_channel(content, channel.slug, ai_agent_name)
-
end
-
-
optimized_content
-
end
-
-
# Generate channel-specific meta descriptions
-
def self.generate_meta_description(content, channel_slug, ai_agent_name = nil)
-
channel = Railspress::PluginApi::Channels.find_channel(channel_slug)
-
return nil unless channel
-
-
agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'seo_analyzer')
-
return nil unless agent&.active?
-
-
settings = Railspress::PluginApi::Channels.channel_settings(channel_slug)
-
-
prompt = "Generate a compelling meta description for #{channel.name} channel (max 160 characters):\n\nContent: #{content}\n\nChannel settings: #{settings.to_json}\n\nMeta description:"
-
-
begin
-
agent.generate_response(prompt)
-
rescue => e
-
Rails.logger.error "AI meta description generation failed: #{e.message}"
-
nil
-
end
-
end
-
-
# Generate channel-specific titles
-
def self.generate_title(content, channel_slug, ai_agent_name = nil)
-
channel = Railspress::PluginApi::Channels.find_channel(channel_slug)
-
return nil unless channel
-
-
agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'post_writer')
-
return nil unless agent&.active?
-
-
settings = Railspress::PluginApi::Channels.channel_settings(channel_slug)
-
-
prompt = "Generate an engaging title for #{channel.name} channel:\n\nContent: #{content}\n\nChannel settings: #{settings.to_json}\n\nTitle:"
-
-
begin
-
agent.generate_response(prompt)
-
rescue => e
-
Rails.logger.error "AI title generation failed: #{e.message}"
-
nil
-
end
-
end
-
-
# Analyze content performance across channels
-
def self.analyze_channel_performance(resource_type, resource_id, ai_agent_name = nil)
-
agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'comments_analyzer')
-
return {} unless agent&.active?
-
-
analysis = {}
-
-
Channel.active.each do |channel|
-
overrides = Railspress::PluginApi::Channels.resource_overrides(resource_type, resource_id, channel.slug)
-
settings = Railspress::PluginApi::Channels.channel_settings(channel.slug)
-
-
prompt = "Analyze content performance for #{channel.name} channel:\n\nResource: #{resource_type} ##{resource_id}\nOverrides: #{overrides.count}\nSettings: #{settings.to_json}\n\nAnalysis:"
-
-
begin
-
analysis[channel.slug] = agent.generate_response(prompt)
-
rescue => e
-
Rails.logger.error "AI channel analysis failed for #{channel.slug}: #{e.message}"
-
analysis[channel.slug] = "Analysis failed"
-
end
-
end
-
-
analysis
-
end
-
-
# Generate channel-specific recommendations
-
def self.generate_recommendations(resource_type, resource_id, ai_agent_name = nil)
-
agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'seo_analyzer')
-
return {} unless agent&.active?
-
-
recommendations = {}
-
-
Channel.active.each do |channel|
-
overrides = Railspress::PluginApi::Channels.resource_overrides(resource_type, resource_id, channel.slug)
-
settings = Railspress::PluginApi::Channels.channel_settings(channel.slug)
-
-
prompt = "Generate optimization recommendations for #{channel.name} channel:\n\nResource: #{resource_type} ##{resource_id}\nCurrent overrides: #{overrides.count}\nChannel settings: #{settings.to_json}\n\nRecommendations:"
-
-
begin
-
recommendations[channel.slug] = agent.generate_response(prompt)
-
rescue => e
-
Rails.logger.error "AI recommendations failed for #{channel.slug}: #{e.message}"
-
recommendations[channel.slug] = "Recommendations unavailable"
-
end
-
end
-
-
recommendations
-
end
-
-
# Auto-create channel overrides based on AI analysis
-
def self.auto_create_overrides(resource_type, resource_id, ai_agent_name = nil)
-
agent = ai_agent_name ? AiAgent.find_by(name: ai_agent_name) : AiAgent.find_by(agent_type: 'content_optimizer')
-
return [] unless agent&.active?
-
-
created_overrides = []
-
-
Channel.active.each do |channel|
-
settings = Railspress::PluginApi::Channels.channel_settings(channel.slug)
-
-
prompt = "Suggest channel-specific overrides for #{channel.name}:\n\nResource: #{resource_type} ##{resource_id}\nChannel settings: #{settings.to_json}\n\nSuggest specific overrides in JSON format: {\"path\": \"setting.key\", \"data\": \"value\", \"kind\": \"override\"}"
-
-
begin
-
response = agent.generate_response(prompt)
-
overrides_data = JSON.parse(response) rescue []
-
-
overrides_data.each do |override_data|
-
override = Railspress::PluginApi::Channels.create_override(
-
channel.slug,
-
resource_type,
-
resource_id,
-
override_data['path'],
-
override_data['data'],
-
override_data['kind'] || 'override'
-
)
-
created_overrides << override if override
-
end
-
rescue => e
-
Rails.logger.error "AI override creation failed for #{channel.slug}: #{e.message}"
-
end
-
end
-
-
created_overrides
-
end
-
-
private
-
-
def self.build_channel_prompt(content, channel, settings, agent)
-
base_prompt = agent.prompt || "Optimize content for the specified channel."
-
-
"#{base_prompt}\n\n" \
-
"Channel: #{channel.name} (#{channel.slug})\n" \
-
"Target Audience: #{channel.metadata['target_audience']}\n" \
-
"Device Type: #{channel.metadata['device_type']}\n" \
-
"Screen Resolution: #{channel.metadata['screen_resolution']}\n" \
-
"Input Method: #{channel.metadata['input_method']}\n" \
-
"Channel Settings: #{settings.to_json}\n\n" \
-
"Original Content:\n#{content}\n\n" \
-
"Optimized Content:"
-
end
-
end
-
end
-
end
-
module Railspress
-
module AiAgentPluginHelper
-
# Easy access to AI Agents from plugins
-
-
# Create a new AI Agent for your plugin
-
#
-
# Example:
-
# Railspress::AiAgentPluginHelper.create_agent(
-
# name: 'My Plugin Agent',
-
# agent_type: 'custom_analyzer',
-
# prompt: 'You are a custom analyzer...',
-
# provider_type: 'openai'
-
# )
-
def self.create_agent(name:, agent_type:, prompt:, provider_type: 'openai', **options)
-
# Find or create provider
-
provider = AiProvider.find_by(provider_type: provider_type, active: true)
-
-
unless provider
-
raise "No active AI provider found for type: #{provider_type}. Please configure one in Admin > AI Agents > Providers"
-
end
-
-
# Create agent
-
AiAgent.create!(
-
name: name,
-
agent_type: agent_type,
-
prompt: prompt,
-
ai_provider: provider,
-
content: options[:content],
-
guidelines: options[:guidelines],
-
rules: options[:rules],
-
tasks: options[:tasks],
-
master_prompt: options[:master_prompt],
-
active: options.fetch(:active, true),
-
position: options.fetch(:position, 0)
-
)
-
end
-
-
# Execute an AI Agent by type
-
#
-
# Example:
-
# result = Railspress::AiAgentPluginHelper.execute('content_summarizer', 'Text to summarize')
-
def self.execute(agent_type, input)
-
agent = AiAgent.active.find_by(agent_type: agent_type)
-
-
unless agent
-
raise "No active agent found for type: #{agent_type}"
-
end
-
-
agent.execute(input)
-
end
-
-
# Execute an AI Agent by name
-
#
-
# Example:
-
# result = Railspress::AiAgentPluginHelper.execute_by_name('My Custom Agent', 'Input text')
-
def self.execute_by_name(name, input)
-
agent = AiAgent.active.find_by(name: name)
-
-
unless agent
-
raise "No active agent found with name: #{name}"
-
end
-
-
agent.execute(input)
-
end
-
-
# List all available AI Agents
-
def self.available_agents
-
AiAgent.active.ordered
-
end
-
-
# List all available providers
-
def self.available_providers
-
AiProvider.active.ordered
-
end
-
-
# Check if an agent type exists
-
def self.agent_exists?(agent_type)
-
AiAgent.active.exists?(agent_type: agent_type)
-
end
-
-
# Get agent by type
-
def self.get_agent(agent_type)
-
AiAgent.active.find_by(agent_type: agent_type)
-
end
-
-
# Update agent settings
-
#
-
# Example:
-
# Railspress::AiAgentPluginHelper.update_agent('content_summarizer',
-
# prompt: 'New prompt...')
-
def self.update_agent(agent_type, **attributes)
-
agent = AiAgent.find_by(agent_type: agent_type)
-
-
unless agent
-
raise "Agent not found: #{agent_type}"
-
end
-
-
agent.update!(attributes)
-
agent
-
end
-
-
# Delete an agent
-
def self.delete_agent(agent_type)
-
agent = AiAgent.find_by(agent_type: agent_type)
-
agent&.destroy
-
end
-
-
# Batch execute multiple agents
-
#
-
# Example:
-
# results = Railspress::AiAgentPluginHelper.batch_execute([
-
# { type: 'content_summarizer', input: 'Text 1' },
-
# { type: 'seo_analyzer', input: 'Text 2' }
-
# ])
-
def self.batch_execute(agent_requests)
-
agent_requests.map do |request|
-
{
-
agent_type: request[:type],
-
result: execute(request[:type], request[:input]),
-
status: 'success'
-
}
-
rescue => e
-
{
-
agent_type: request[:type],
-
error: e.message,
-
status: 'error'
-
}
-
end
-
end
-
-
# Register a custom agent type (add to AiAgent::AGENT_TYPES)
-
def self.register_agent_type(type, description = nil)
-
unless AiAgent::AGENT_TYPES.include?(type)
-
AiAgent::AGENT_TYPES << type
-
end
-
end
-
end
-
end
-
-
-
-
-
-
# frozen_string_literal: true
-
-
require 'loofah'
-
-
module Railspress
-
class HtmlSanitizer
-
# Allow safe HTML tags and attributes for content editors
-
ALLOWED_TAGS = %w[
-
p br strong em b i u s strike del ins mark small sub sup
-
h1 h2 h3 h4 h5 h6
-
ul ol li dl dt dd
-
blockquote q code pre kbd samp var
-
a img
-
table thead tbody tfoot tr th td caption colgroup col
-
div span section article aside header footer nav main
-
figure figcaption
-
hr
-
abbr cite dfn time
-
].freeze
-
-
ALLOWED_ATTRIBUTES = {
-
'a' => %w[href title rel target],
-
'img' => %w[src alt title width height],
-
'div' => %w[class id],
-
'span' => %w[class id],
-
'p' => %w[class id],
-
'h1' => %w[id],
-
'h2' => %w[id],
-
'h3' => %w[id],
-
'h4' => %w[id],
-
'h5' => %w[id],
-
'h6' => %w[id],
-
'section' => %w[class id],
-
'article' => %w[class id],
-
'table' => %w[class],
-
'tr' => %w[class],
-
'td' => %w[class colspan rowspan],
-
'th' => %w[class colspan rowspan],
-
'code' => %w[class],
-
'pre' => %w[class],
-
'blockquote' => %w[cite]
-
}.freeze
-
-
ALLOWED_PROTOCOLS = %w[http https mailto].freeze
-
-
class << self
-
# Sanitize HTML content for posts/pages
-
def sanitize_content(html)
-
return '' if html.blank?
-
-
scrubber = ContentScrubber.new
-
Loofah.fragment(html).scrub!(scrubber).to_s
-
end
-
-
# Sanitize HTML from GrapesJS (template editor)
-
def sanitize_template(html)
-
return '' if html.blank?
-
-
scrubber = TemplateScrubber.new
-
Loofah.fragment(html).scrub!(scrubber).to_s
-
end
-
-
# Strip all HTML tags, leaving only text
-
def strip_tags(html)
-
return '' if html.blank?
-
-
Loofah.fragment(html).text(encode_special_chars: false)
-
end
-
-
# Sanitize for safe display in admin (allows more tags)
-
def sanitize_admin(html)
-
return '' if html.blank?
-
-
scrubber = AdminScrubber.new
-
Loofah.fragment(html).scrub!(scrubber).to_s
-
end
-
-
# Check if HTML contains any disallowed content
-
def contains_unsafe_content?(html)
-
return false if html.blank?
-
-
# Check for script tags
-
return true if html.match?(/<script[\s>]/i)
-
-
# Check for event handlers
-
return true if html.match?(/on\w+\s*=/i)
-
-
# Check for javascript: protocol
-
return true if html.match?(/javascript:/i)
-
-
# Check for data: protocol (except images)
-
return true if html.match?(/data:(?!image)/i)
-
-
false
-
end
-
end
-
-
# Scrubber for content (posts/pages)
-
class ContentScrubber < Loofah::Scrubber
-
def initialize
-
@direction = :top_down
-
end
-
-
def scrub(node)
-
return CONTINUE if node.text?
-
-
# Remove comments
-
return STOP if node.comment?
-
-
# Check if tag is allowed
-
unless ALLOWED_TAGS.include?(node.name)
-
node.before(node.children)
-
return STOP
-
end
-
-
# Remove dangerous attributes
-
node.attributes.each do |name, _attr|
-
# Skip if attribute is allowed for this tag
-
next if ALLOWED_ATTRIBUTES[node.name]&.include?(name)
-
-
# Remove attribute
-
node.remove_attribute(name)
-
end
-
-
# Sanitize href/src attributes
-
sanitize_url_attributes(node)
-
-
CONTINUE
-
end
-
-
private
-
-
def sanitize_url_attributes(node)
-
%w[href src].each do |attr|
-
next unless node[attr]
-
-
url = node[attr].strip
-
-
# Remove javascript: and data: protocols
-
if url.match?(/^(javascript|data):/i)
-
node.remove_attribute(attr)
-
next
-
end
-
-
# Ensure protocol is allowed
-
if url.match?(/^(\w+):/) && !ALLOWED_PROTOCOLS.any? { |p| url.start_with?("#{p}:") }
-
node.remove_attribute(attr)
-
end
-
end
-
end
-
end
-
-
# Scrubber for templates (GrapesJS)
-
class TemplateScrubber < ContentScrubber
-
# Additional allowed tags for templates
-
TEMPLATE_TAGS = (ALLOWED_TAGS + %w[
-
style
-
]).freeze
-
-
# Additional allowed attributes for templates
-
TEMPLATE_ATTRIBUTES = ALLOWED_ATTRIBUTES.merge(
-
'style' => %w[type],
-
'div' => %w[class id data-gjs-type],
-
'section' => %w[class id data-gjs-type]
-
).freeze
-
-
def scrub(node)
-
return CONTINUE if node.text?
-
return STOP if node.comment?
-
-
# Allow more tags for templates
-
unless TEMPLATE_TAGS.include?(node.name)
-
node.before(node.children)
-
return STOP
-
end
-
-
# Remove dangerous attributes but keep template-specific ones
-
node.attributes.each do |name, _attr|
-
next if TEMPLATE_ATTRIBUTES[node.name]&.include?(name)
-
node.remove_attribute(name)
-
end
-
-
# For style tags, sanitize content
-
if node.name == 'style'
-
sanitize_css(node)
-
end
-
-
sanitize_url_attributes(node)
-
-
CONTINUE
-
end
-
-
private
-
-
def sanitize_css(node)
-
# Remove any @import or expression() from CSS
-
css = node.content
-
css.gsub!(/@import/i, '')
-
css.gsub!(/expression\s*\(/i, '')
-
css.gsub!(/javascript:/i, '')
-
node.content = css
-
end
-
end
-
-
# Scrubber for admin interface (most permissive)
-
class AdminScrubber < TemplateScrubber
-
# Admin can see more but still no scripts
-
ADMIN_TAGS = (TEMPLATE_TAGS + %w[
-
video audio source track
-
iframe embed object
-
canvas svg
-
details summary
-
]).freeze
-
-
def scrub(node)
-
return CONTINUE if node.text?
-
return STOP if node.comment?
-
-
# Block script tags always
-
if node.name == 'script'
-
return STOP
-
end
-
-
# Allow admin tags
-
unless ADMIN_TAGS.include?(node.name)
-
node.before(node.children)
-
return STOP
-
end
-
-
# Remove event handler attributes
-
node.attributes.each do |name, _attr|
-
if name.match?(/^on/i)
-
node.remove_attribute(name)
-
end
-
end
-
-
CONTINUE
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
# frozen_string_literal: true
-
-
1
module Railspress
-
1
module Liquid
-
1
module ConsentTags
-
-
# Register consent-related Liquid tags
-
1
def self.register_tags
-
2
::Liquid::Template.register_tag('consent_banner', ConsentBannerTag)
-
2
::Liquid::Template.register_tag('consent_css', ConsentCssTag)
-
2
::Liquid::Template.register_tag('consent_pixel', ConsentPixelTag)
-
2
::Liquid::Template.register_tag('consent_script', ConsentScriptTag)
-
2
::Liquid::Template.register_tag('consent_status', ConsentStatusTag)
-
2
::Liquid::Template.register_tag('consent_management_link', ConsentManagementLinkTag)
-
2
::Liquid::Template.register_tag('consent_assets', ConsentAssetsTag)
-
2
::Liquid::Template.register_tag('consent_config', ConsentConfigTag)
-
2
::Liquid::Template.register_tag('consent_analytics', ConsentAnalyticsTag)
-
2
::Liquid::Template.register_tag('consent_compliance', ConsentComplianceTag)
-
end
-
end
-
-
# Render consent banner
-
1
class ConsentBannerTag < ::Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
1
def render(context)
-
# Get consent configuration
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return '' unless consent_config
-
-
# Get user's region and consent data
-
region = get_user_region(context)
-
user_consent = get_user_consent_data(context)
-
-
# Generate banner HTML
-
consent_config.generate_banner_html(region, user_consent)
-
end
-
-
1
private
-
-
1
def get_user_region(context)
-
# Try to get region from context or request
-
context['user_region'] ||
-
then: 0
else: 0
context['request']&.remote_ip ||
-
'unknown'
-
end
-
-
1
def get_user_consent_data(context)
-
# Get user consent data from context
-
user = context['user']
-
then: 0
else: 0
else: 0
then: 0
return [] unless user&.respond_to?(:user_consents)
-
-
user.user_consents.map do |consent|
-
{
-
consent_type: consent.consent_type,
-
granted: consent.granted?
-
}
-
end
-
end
-
end
-
-
# Render consent CSS
-
1
class ConsentCssTag < ::Liquid::Tag
-
1
def render(context)
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return '' unless consent_config
-
-
consent_config.generate_banner_css
-
end
-
end
-
-
# Render consent-aware pixel
-
1
class ConsentPixelTag < ::Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
1
def render(context)
-
# Parse pixel ID from markup
-
pixel_id = @markup.split.first
-
-
else: 0
then: 0
return '' unless pixel_id
-
-
# Find pixel
-
pixel = Pixel.find_by(id: pixel_id)
-
then: 0
else: 0
else: 0
then: 0
return '' unless pixel&.active?
-
-
# Get consent configuration
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return pixel.render_code unless consent_config
-
-
# Check if pixel requires consent
-
required_consent = consent_config.get_consent_categories_for_pixel(pixel.pixel_type)
-
-
if required_consent.any?
-
then: 0
# Pixel requires consent - wrap in consent-aware code
-
consent_categories = required_consent.join(',')
-
-
<<~HTML
-
<div data-pixel-type="#{pixel.pixel_type}" data-consent-categories="#{consent_categories}" class="consent-pixel" style="display: none;">
-
#{pixel.render_code}
-
</div>
-
HTML
-
else
-
else: 0
# Pixel doesn't require consent - render normally
-
pixel.render_code
-
end
-
end
-
end
-
-
# Render consent script
-
1
class ConsentScriptTag < ::Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
1
def render(context)
-
script_type = @markup.split.first || 'init'
-
-
case script_type
-
when: 0
when 'init'
-
render_init_script(context)
-
when: 0
when 'config'
-
render_config_script(context)
-
when: 0
when 'pixel'
-
render_pixel_script(context)
-
when: 0
when 'analytics'
-
render_analytics_script(context)
-
else: 0
else
-
''
-
end
-
end
-
-
1
private
-
-
1
def render_init_script(context)
-
else: 0
then: 0
return '' unless ConsentConfiguration.active.exists?
-
-
config_json = get_consent_config_json(context)
-
-
<<~HTML
-
<script>
-
document.addEventListener('DOMContentLoaded', function() {
-
// Initialize consent manager
-
if (typeof ConsentManager !== 'undefined') {
-
window.consentManager = new ConsentManager({
-
config: #{config_json},
-
debug: #{Rails.env.development?}
-
});
-
}
-
});
-
</script>
-
HTML
-
end
-
-
1
def render_config_script(context)
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return '' unless consent_config
-
-
config_json = get_consent_config_json(context)
-
-
<<~HTML
-
<script>
-
window.consentConfig = #{config_json};
-
</script>
-
HTML
-
end
-
-
1
def render_pixel_script(context)
-
<<~HTML
-
<script>
-
// Consent-aware pixel loading
-
document.addEventListener('DOMContentLoaded', function() {
-
const consentPixels = document.querySelectorAll('[data-pixel-type][data-consent-categories]');
-
-
consentPixels.forEach(function(pixel) {
-
const pixelType = pixel.dataset.pixelType;
-
const requiredCategories = pixel.dataset.consentCategories.split(',');
-
-
// Check if user has required consent
-
let hasConsent = true;
-
if (window.userConsentData) {
-
hasConsent = requiredCategories.every(function(category) {
-
return window.userConsentData[category] && window.userConsentData[category].granted;
-
});
-
}
-
-
if (hasConsent) {
-
pixel.style.display = '';
-
pixel.classList.remove('consent-disabled');
-
} else {
-
pixel.style.display = 'none';
-
pixel.classList.add('consent-disabled');
-
}
-
});
-
});
-
</script>
-
HTML
-
end
-
-
1
def render_analytics_script(context)
-
<<~HTML
-
<script>
-
// Consent-aware analytics
-
document.addEventListener('DOMContentLoaded', function() {
-
// Track consent events
-
if (window.consentManager) {
-
window.consentManager.on('consent_granted', function(data) {
-
// Track consent granted event
-
if (typeof gtag !== 'undefined') {
-
gtag('event', 'consent_granted', {
-
'event_category': 'consent',
-
'event_label': data.category
-
});
-
}
-
});
-
-
window.consentManager.on('consent_withdrawn', function(data) {
-
// Track consent withdrawn event
-
if (typeof gtag !== 'undefined') {
-
gtag('event', 'consent_withdrawn', {
-
'event_category': 'consent',
-
'event_label': data.category
-
});
-
}
-
});
-
}
-
});
-
</script>
-
HTML
-
end
-
-
1
def get_consent_config_json(context)
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return '{}' unless consent_config
-
-
{
-
consent_categories: consent_config.consent_categories_with_defaults,
-
banner_settings: consent_config.banner_settings_with_defaults,
-
geolocation_settings: consent_config.geolocation_settings_with_defaults,
-
pixel_consent_mapping: consent_config.pixel_consent_mapping_with_defaults,
-
version: consent_config.version || '1.0'
-
}.to_json
-
end
-
end
-
-
# Render consent status
-
1
class ConsentStatusTag < ::Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
1
def render(context)
-
category = @markup.split.first
-
-
else: 0
then: 0
return '' unless category
-
-
user = context['user']
-
then: 0
else: 0
else: 0
then: 0
return '' unless user&.respond_to?(:user_consents)
-
-
consent = user.user_consents.find_by(consent_type: category)
-
else: 0
then: 0
return '' unless consent
-
-
then: 0
else: 0
status_class = consent.granted? ? 'consent-granted' : 'consent-withdrawn'
-
then: 0
else: 0
status_text = consent.granted? ? 'Granted' : 'Withdrawn'
-
-
"<span class=\"consent-status #{status_class}\">#{status_text}</span>"
-
end
-
end
-
-
# Render consent management link
-
1
class ConsentManagementLinkTag < ::Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
1
def render(context)
-
else: 0
then: 0
return '' unless ConsentConfiguration.active.exists?
-
-
then: 0
else: 0
text = @markup.present? ? @markup : 'Manage Cookie Preferences'
-
-
"<a href=\"#\" class=\"consent-management-link\" onclick=\"ConsentManager.showPreferencesModal(); return false;\">#{text}</a>"
-
end
-
end
-
-
# Render all consent assets
-
1
class ConsentAssetsTag < ::Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
1
def render(context)
-
else: 0
then: 0
return '' unless ConsentConfiguration.active.exists?
-
-
consent_config = ConsentConfiguration.active.first
-
-
# Get user's region and consent data
-
region = get_user_region(context)
-
user_consent = get_user_consent_data(context)
-
-
# Generate all assets
-
css = consent_config.generate_banner_css
-
html = consent_config.generate_banner_html(region, user_consent)
-
script = generate_consent_script(context)
-
-
<<~HTML
-
<style>
-
#{css}
-
</style>
-
#{html}
-
#{script}
-
HTML
-
end
-
-
1
private
-
-
1
def get_user_region(context)
-
context['user_region'] ||
-
then: 0
else: 0
context['request']&.remote_ip ||
-
'unknown'
-
end
-
-
1
def get_user_consent_data(context)
-
user = context['user']
-
then: 0
else: 0
else: 0
then: 0
return [] unless user&.respond_to?(:user_consents)
-
-
user.user_consents.map do |consent|
-
{
-
consent_type: consent.consent_type,
-
granted: consent.granted?
-
}
-
end
-
end
-
-
1
def generate_consent_script(context)
-
config_json = get_consent_config_json(context)
-
-
<<~HTML
-
<script>
-
document.addEventListener('DOMContentLoaded', function() {
-
// Initialize consent manager
-
if (typeof ConsentManager !== 'undefined') {
-
window.consentManager = new ConsentManager({
-
config: #{config_json},
-
debug: #{Rails.env.development?}
-
});
-
}
-
-
// Handle consent-aware pixels
-
const consentPixels = document.querySelectorAll('[data-pixel-type][data-consent-categories]');
-
-
consentPixels.forEach(function(pixel) {
-
const pixelType = pixel.dataset.pixelType;
-
const requiredCategories = pixel.dataset.consentCategories.split(',');
-
-
let hasConsent = true;
-
if (window.userConsentData) {
-
hasConsent = requiredCategories.every(function(category) {
-
return window.userConsentData[category] && window.userConsentData[category].granted;
-
});
-
}
-
-
if (hasConsent) {
-
pixel.style.display = '';
-
pixel.classList.remove('consent-disabled');
-
} else {
-
pixel.style.display = 'none';
-
pixel.classList.add('consent-disabled');
-
}
-
});
-
});
-
</script>
-
HTML
-
end
-
-
1
def get_consent_config_json(context)
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return '{}' unless consent_config
-
-
{
-
consent_categories: consent_config.consent_categories_with_defaults,
-
banner_settings: consent_config.banner_settings_with_defaults,
-
geolocation_settings: consent_config.geolocation_settings_with_defaults,
-
pixel_consent_mapping: consent_config.pixel_consent_mapping_with_defaults,
-
version: consent_config.version || '1.0'
-
}.to_json
-
end
-
end
-
-
# Render consent configuration
-
1
class ConsentConfigTag < ::Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
1
def render(context)
-
consent_config = ConsentConfiguration.active.first
-
else: 0
then: 0
return '{}' unless consent_config
-
-
config_type = @markup.split.first || 'all'
-
-
case config_type
-
when: 0
when 'categories'
-
consent_config.consent_categories_with_defaults.to_json
-
when: 0
when 'banner'
-
consent_config.banner_settings_with_defaults.to_json
-
when: 0
when 'geolocation'
-
consent_config.geolocation_settings_with_defaults.to_json
-
when: 0
when 'pixels'
-
consent_config.pixel_consent_mapping_with_defaults.to_json
-
else: 0
else
-
{
-
consent_categories: consent_config.consent_categories_with_defaults,
-
banner_settings: consent_config.banner_settings_with_defaults,
-
geolocation_settings: consent_config.geolocation_settings_with_defaults,
-
pixel_consent_mapping: consent_config.pixel_consent_mapping_with_defaults,
-
version: consent_config.version || '1.0'
-
}.to_json
-
end
-
end
-
end
-
-
# Render consent analytics
-
1
class ConsentAnalyticsTag < ::Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
1
def render(context)
-
analytics_type = @markup.split.first || 'events'
-
-
case analytics_type
-
when: 0
when 'events'
-
render_consent_events(context)
-
when: 0
when 'stats'
-
render_consent_stats(context)
-
when: 0
when 'compliance'
-
render_compliance_stats(context)
-
else: 0
else
-
''
-
end
-
end
-
-
1
private
-
-
1
def render_consent_events(context)
-
<<~HTML
-
<script>
-
// Consent analytics events
-
document.addEventListener('DOMContentLoaded', function() {
-
// Track consent banner interactions
-
document.addEventListener('click', function(e) {
-
if (e.target.matches('.consent-btn')) {
-
const action = e.target.textContent.toLowerCase().replace(/\s+/g, '_');
-
-
if (typeof gtag !== 'undefined') {
-
gtag('event', 'consent_banner_' + action, {
-
'event_category': 'consent',
-
'event_label': 'banner_interaction'
-
});
-
}
-
}
-
});
-
-
// Track consent preference changes
-
document.addEventListener('change', function(e) {
-
if (e.target.matches('.consent-toggle input[type="checkbox"]')) {
-
const category = e.target.dataset.category;
-
const action = e.target.checked ? 'enabled' : 'disabled';
-
-
if (typeof gtag !== 'undefined') {
-
gtag('event', 'consent_preference_' + action, {
-
'event_category': 'consent',
-
'event_label': category
-
});
-
}
-
}
-
});
-
});
-
</script>
-
HTML
-
end
-
-
1
def render_consent_stats(context)
-
# Get consent statistics
-
stats = {
-
total_consents: UserConsent.count,
-
granted_consents: UserConsent.granted.count,
-
withdrawn_consents: UserConsent.withdrawn.count,
-
consent_rate: calculate_consent_rate
-
}
-
-
<<~HTML
-
<script>
-
window.consentStats = #{stats.to_json};
-
</script>
-
HTML
-
end
-
-
1
def render_compliance_stats(context)
-
# Get compliance statistics
-
compliance = {
-
gdpr_compliant: check_gdpr_compliance,
-
ccpa_compliant: check_ccpa_compliance,
-
overall_score: calculate_overall_compliance_score
-
}
-
-
<<~HTML
-
<script>
-
window.complianceStats = #{compliance.to_json};
-
</script>
-
HTML
-
end
-
-
1
def calculate_consent_rate
-
total_users = User.count
-
users_with_consent = User.joins(:user_consents).distinct.count
-
-
then: 0
else: 0
return 0 if total_users == 0
-
-
(users_with_consent.to_f / total_users * 100).round(2)
-
end
-
-
1
def check_gdpr_compliance
-
# Simplified GDPR compliance check
-
{
-
data_subject_rights: UserConsent.exists?,
-
consent_management: ConsentConfiguration.active.exists?,
-
data_processing_records: true,
-
privacy_by_design: true,
-
score: 85
-
}
-
end
-
-
1
def check_ccpa_compliance
-
# Simplified CCPA compliance check
-
{
-
consumer_rights: PersonalDataExportRequest.exists?,
-
opt_out_mechanism: UserConsent.withdrawn.exists?,
-
data_disclosure: true,
-
score: 80
-
}
-
end
-
-
1
def calculate_overall_compliance_score
-
gdpr_score = check_gdpr_compliance[:score]
-
ccpa_score = check_ccpa_compliance[:score]
-
-
((gdpr_score + ccpa_score) / 2.0).round(2)
-
end
-
end
-
-
# Render compliance information
-
1
class ConsentComplianceTag < ::Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
1
def render(context)
-
compliance_type = @markup.split.first || 'status'
-
-
case compliance_type
-
when: 0
when 'status'
-
render_compliance_status(context)
-
when: 0
when 'score'
-
render_compliance_score(context)
-
when: 0
when 'report'
-
render_compliance_report(context)
-
else: 0
else
-
''
-
end
-
end
-
-
1
private
-
-
1
def render_compliance_status(context)
-
score = calculate_overall_compliance_score
-
-
when: 0
status_class = case score
-
when: 0
when 90..100 then 'excellent'
-
when: 0
when 80..89 then 'good'
-
else: 0
when 70..79 then 'fair'
-
else 'needs-improvement'
-
end
-
-
when: 0
status_text = case score
-
when: 0
when 90..100 then 'Excellent'
-
when: 0
when 80..89 then 'Good'
-
else: 0
when 70..79 then 'Fair'
-
else 'Needs Improvement'
-
end
-
-
"<span class=\"compliance-status #{status_class}\">#{status_text} (#{score}%)</span>"
-
end
-
-
1
def render_compliance_score(context)
-
score = calculate_overall_compliance_score
-
"<span class=\"compliance-score\">#{score}%</span>"
-
end
-
-
1
def render_compliance_report(context)
-
report = generate_compliance_report
-
-
<<~HTML
-
<div class="compliance-report">
-
<h3>Privacy Compliance Report</h3>
-
<div class="compliance-section">
-
<h4>GDPR Compliance</h4>
-
<p>Score: #{report[:gdpr_compliance][:score]}%</p>
-
</div>
-
<div class="compliance-section">
-
<h4>CCPA Compliance</h4>
-
<p>Score: #{report[:ccpa_compliance][:score]}%</p>
-
</div>
-
<div class="compliance-section">
-
<h4>Overall Score</h4>
-
<p>Score: #{report[:overall_score]}%</p>
-
</div>
-
</div>
-
HTML
-
end
-
-
1
def calculate_overall_compliance_score
-
gdpr_score = 85 # Simplified
-
ccpa_score = 80 # Simplified
-
-
((gdpr_score + ccpa_score) / 2.0).round(2)
-
end
-
-
1
def generate_compliance_report
-
{
-
gdpr_compliance: {
-
score: 85,
-
data_subject_rights: UserConsent.exists?,
-
consent_management: ConsentConfiguration.active.exists?,
-
data_processing_records: true,
-
privacy_by_design: true
-
},
-
ccpa_compliance: {
-
score: 80,
-
consumer_rights: PersonalDataExportRequest.exists?,
-
opt_out_mechanism: UserConsent.withdrawn.exists?,
-
data_disclosure: true
-
},
-
overall_score: calculate_overall_compliance_score
-
}
-
end
-
end
-
end
-
end
-
-
# Register the consent tags
-
1
Railspress::Liquid::ConsentTags.register_tags
-
# frozen_string_literal: true
-
-
# Image optimization tag for responsive images with WebP/AVIF support
-
1
class ImageOptimizedTag < Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
1
def render(context)
-
parsed = parse_markup
-
else: 0
then: 0
return '' unless parsed[:src]
-
-
upload = get_upload(context)
-
then: 0
else: 0
then: 0
else: 0
else: 0
then: 0
return '' unless upload&.file_attachment&.attached?
-
-
generate_responsive_image(upload, context)
-
end
-
-
1
private
-
-
1
def parse_markup
-
attributes = {}
-
@markup.scan(/(\w+)=["']([^"']*)["']/) do |key, value|
-
attributes[key.to_sym] = value
-
end
-
attributes
-
rescue
-
{}
-
end
-
-
1
def get_upload(context)
-
else: 0
then: 0
return nil unless @markup.include?('upload=')
-
-
upload_id = @markup.match(/upload=["'](\d+)["']/)[1]
-
Upload.find_by(id: upload_id)
-
rescue
-
nil
-
end
-
-
1
def generate_responsive_image(upload, context)
-
alt_text = @markup.match(/alt=["']([^"']*)["']/)[1] rescue 'Image'
-
css_class = @markup.match(/class=["']([^"']*)["']/)[1] rescue ''
-
-
# Generate source sets for different formats
-
webp_srcset = generate_source_set(upload, 'webp', 'image/webp')
-
avif_srcset = generate_source_set(upload, 'avif', 'image/avif')
-
-
# Fallback srcset for original format
-
original_srcset = generate_srcset(upload, upload.file_type)
-
-
# Generate the picture element
-
<<~HTML
-
<picture>
-
<source srcset="#{avif_srcset}" type="image/avif">
-
<source srcset="#{webp_srcset}" type="image/webp">
-
<img src="#{upload.file_url}"
-
srcset="#{original_srcset}"
-
alt="#{alt_text}"
-
class="#{css_class}"
-
loading="lazy">
-
</picture>
-
#{generate_lazy_loading_script}
-
HTML
-
end
-
-
1
def generate_source_set(upload, format, mime_type)
-
# Generate srcset for optimized format
-
generate_srcset(upload, format)
-
end
-
-
1
def generate_srcset(upload, format)
-
then: 0
else: 0
else: 0
then: 0
return upload.file_url unless upload.variants&.dig(format)
-
-
variants = upload.variants[format]
-
srcset_parts = []
-
-
variants.each do |size, url|
-
srcset_parts << "#{url} #{size}w"
-
end
-
-
srcset_parts.join(', ')
-
rescue
-
upload.file_url
-
end
-
-
1
def generate_img_tag(upload, context)
-
alt_text = @markup.match(/alt=["']([^"']*)["']/)[1] rescue 'Image'
-
css_class = @markup.match(/class=["']([^"']*)["']/)[1] rescue ''
-
-
"<img src=\"#{upload.file_url}\" alt=\"#{alt_text}\" class=\"#{css_class}\" loading=\"lazy\">"
-
end
-
-
1
def generate_lazy_loading_script
-
<<~HTML
-
<script>
-
if ('IntersectionObserver' in window) {
-
const images = document.querySelectorAll('img[loading="lazy"]');
-
const imageObserver = new IntersectionObserver((entries, observer) => {
-
entries.forEach(entry => {
-
if (entry.isIntersecting) {
-
const img = entry.target;
-
img.src = img.dataset.src || img.src;
-
img.classList.remove('lazy');
-
imageObserver.unobserve(img);
-
}
-
});
-
});
-
-
images.forEach(img => imageObserver.observe(img));
-
}
-
</script>
-
HTML
-
end
-
end
-
-
# Background image optimization tag
-
1
class BackgroundImageOptimizedTag < Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
@markup = markup.strip
-
end
-
-
1
def render(context)
-
parsed = parse_markup
-
else: 0
then: 0
return '' unless parsed[:upload]
-
-
upload = get_upload(context)
-
then: 0
else: 0
then: 0
else: 0
else: 0
then: 0
return '' unless upload&.file_attachment&.attached?
-
-
generate_background_image_css(upload, context)
-
end
-
-
1
private
-
-
1
def parse_markup
-
attributes = {}
-
@markup.scan(/(\w+)=["']([^"']*)["']/) do |key, value|
-
attributes[key.to_sym] = value
-
end
-
attributes
-
rescue
-
{}
-
end
-
-
1
def get_upload(context)
-
else: 0
then: 0
return nil unless @markup.include?('upload=')
-
-
upload_id = @markup.match(/upload=["'](\d+)["']/)[1]
-
Upload.find_by(id: upload_id)
-
rescue
-
nil
-
end
-
-
1
def generate_background_image_css(upload, context)
-
css_class = @markup.match(/class=["']([^"']*)["']/)[1] rescue 'bg-image'
-
-
# Generate CSS with fallbacks
-
<<~CSS
-
<style>
-
.#{css_class} {
-
background-image: url('#{upload.file_url}');
-
background-size: cover;
-
background-position: center;
-
background-repeat: no-repeat;
-
}
-
-
@supports (background-image: url('#{upload.file_url}')) {
-
.#{css_class} {
-
background-image: url('#{upload.file_url}');
-
}
-
}
-
</style>
-
CSS
-
end
-
end
-
-
# Bulk optimization tag for admin use
-
1
class BulkOptimizeTag < Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
end
-
-
1
def render(context)
-
generate_bulk_optimization_interface
-
end
-
-
1
private
-
-
1
def generate_bulk_optimization_interface
-
<<~HTML
-
<div class="bulk-optimization-interface">
-
<h3>Bulk Image Optimization</h3>
-
<div class="optimization-controls">
-
<button id="start-optimization" class="btn btn-primary">
-
Start Optimization
-
</button>
-
<button id="stop-optimization" class="btn btn-secondary" disabled>
-
Stop Optimization
-
</button>
-
</div>
-
-
<div class="optimization-progress" style="display: none;">
-
<div class="progress-bar">
-
<div class="progress-fill" style="width: 0%"></div>
-
</div>
-
<div class="progress-text">0% Complete</div>
-
</div>
-
-
<div class="optimization-stats">
-
<div class="stat">
-
<span class="stat-label">Images Processed:</span>
-
<span class="stat-value" id="processed-count">0</span>
-
</div>
-
<div class="stat">
-
<span class="stat-label">Space Saved:</span>
-
<span class="stat-value" id="space-saved">0 MB</span>
-
</div>
-
</div>
-
-
<div class="optimization-log">
-
<h4>Optimization Log</h4>
-
<div id="log-content" class="log-content"></div>
-
</div>
-
</div>
-
-
<script>
-
document.addEventListener('DOMContentLoaded', function() {
-
const startBtn = document.getElementById('start-optimization');
-
const stopBtn = document.getElementById('stop-optimization');
-
const progressBar = document.querySelector('.progress-bar');
-
const progressFill = document.querySelector('.progress-fill');
-
const progressText = document.querySelector('.progress-text');
-
const processedCount = document.getElementById('processed-count');
-
const spaceSaved = document.getElementById('space-saved');
-
const logContent = document.getElementById('log-content');
-
-
let isOptimizing = false;
-
let processedImages = 0;
-
let totalSpaceSaved = 0;
-
-
startBtn.addEventListener('click', function() {
-
if (isOptimizing) return;
-
-
isOptimizing = true;
-
startBtn.disabled = true;
-
stopBtn.disabled = false;
-
progressBar.style.display = 'block';
-
-
// Simulate optimization process
-
simulateOptimization();
-
});
-
-
stopBtn.addEventListener('click', function() {
-
isOptimizing = false;
-
startBtn.disabled = false;
-
stopBtn.disabled = true;
-
progressBar.style.display = 'none';
-
});
-
-
function simulateOptimization() {
-
if (!isOptimizing) return;
-
-
// Simulate processing images
-
processedImages++;
-
totalSpaceSaved += Math.random() * 0.5; // Random space saved
-
-
// Update UI
-
processedCount.textContent = processedImages;
-
spaceSaved.textContent = totalSpaceSaved.toFixed(2) + ' MB';
-
-
// Update progress
-
const progress = Math.min((processedImages / 100) * 100, 100);
-
progressFill.style.width = progress + '%';
-
progressText.textContent = Math.round(progress) + '% Complete';
-
-
// Add log entry
-
const logEntry = document.createElement('div');
-
logEntry.textContent = `Processed image ${processedImages}: Saved ${(Math.random() * 0.5).toFixed(2)} MB`;
-
logContent.appendChild(logEntry);
-
logContent.scrollTop = logContent.scrollHeight;
-
-
if (processedImages < 100 && isOptimizing) {
-
setTimeout(simulateOptimization, 100);
-
} else {
-
// Optimization complete
-
isOptimizing = false;
-
startBtn.disabled = false;
-
stopBtn.disabled = true;
-
progressBar.style.display = 'none';
-
}
-
}
-
});
-
</script>
-
HTML
-
end
-
end
-
-
# Image optimization stats tag
-
1
class OptimizationStatsTag < Liquid::Tag
-
1
def initialize(tag_name, markup, options)
-
super
-
end
-
-
1
def render(context)
-
generate_optimization_stats
-
end
-
-
1
private
-
-
1
def generate_optimization_stats
-
stats = calculate_optimization_stats
-
-
<<~HTML
-
<div class="optimization-stats">
-
<h4>Image Optimization Statistics</h4>
-
<div class="stats-grid">
-
<div class="stat-item">
-
<span class="stat-number">#{stats[:total_images]}</span>
-
<span class="stat-label">Total Images</span>
-
</div>
-
<div class="stat-item">
-
<span class="stat-number">#{stats[:optimized_images]}</span>
-
<span class="stat-label">Optimized</span>
-
</div>
-
<div class="stat-item">
-
<span class="stat-number">#{stats[:webp_variants]}</span>
-
<span class="stat-label">WebP Variants</span>
-
</div>
-
<div class="stat-item">
-
<span class="stat-number">#{stats[:avif_variants]}</span>
-
<span class="stat-label">AVIF Variants</span>
-
</div>
-
<div class="stat-item">
-
<span class="stat-number">#{stats[:space_saved]} MB</span>
-
<span class="stat-label">Space Saved</span>
-
</div>
-
<div class="stat-item">
-
<span class="stat-number">#{stats[:optimization_percentage]}%</span>
-
<span class="stat-label">Optimization Rate</span>
-
</div>
-
</div>
-
</div>
-
HTML
-
end
-
-
1
def calculate_optimization_stats
-
total_images = Upload.joins(:file_attachment).where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] }).count
-
optimized_images = Upload.where.not(variants: [nil, {}]).joins(:file_attachment).where(active_storage_blobs: { content_type: ['image/jpeg', 'image/png', 'image/gif'] }).count
-
-
webp_variants = Upload.where("variants LIKE ?", '%webp%').count
-
avif_variants = Upload.where("variants LIKE ?", '%avif%').count
-
-
# Calculate space saved (simplified)
-
space_saved = (total_images * 0.3).round(1) # Assume 30% average savings
-
then: 0
else: 0
optimization_percentage = total_images > 0 ? ((optimized_images.to_f / total_images) * 100).round(1) : 0
-
-
{
-
total_images: total_images,
-
optimized_images: optimized_images,
-
webp_variants: webp_variants,
-
avif_variants: avif_variants,
-
space_saved: space_saved,
-
optimization_percentage: optimization_percentage
-
}
-
end
-
end
-
-
# Register the Liquid tags
-
1
Liquid::Template.register_tag('image_optimized', ImageOptimizedTag)
-
1
Liquid::Template.register_tag('background_image_optimized', BackgroundImageOptimizedTag)
-
1
Liquid::Template.register_tag('bulk_optimize', BulkOptimizeTag)
-
1
Liquid::Template.register_tag('optimization_stats', OptimizationStatsTag)
-
# frozen_string_literal: true
-
-
module Railspress
-
# Newsletter-specific shortcodes
-
class NewsletterShortcodes
-
def self.register_all
-
# [newsletter] - Basic newsletter signup form
-
Railspress::ShortcodeProcessor.register('newsletter') do |attrs, content|
-
render_newsletter_form(attrs, content)
-
end
-
-
# [newsletter_inline] - Inline newsletter form (horizontal)
-
Railspress::ShortcodeProcessor.register('newsletter_inline') do |attrs, content|
-
render_inline_form(attrs, content)
-
end
-
-
# [newsletter_popup] - Popup newsletter form
-
Railspress::ShortcodeProcessor.register('newsletter_popup') do |attrs, content|
-
render_popup_form(attrs, content)
-
end
-
-
# [newsletter_count] - Display subscriber count
-
Railspress::ShortcodeProcessor.register('newsletter_count') do |attrs, content|
-
count = Subscriber.confirmed.count
-
"<span class=\"newsletter-count\">#{number_with_delimiter(count)}</span>"
-
end
-
-
# [newsletter_stats] - Display newsletter statistics
-
Railspress::ShortcodeProcessor.register('newsletter_stats') do |attrs, content|
-
render_stats(attrs)
-
end
-
end
-
-
private
-
-
def self.render_newsletter_form(attrs, content)
-
title = attrs['title'] || 'Subscribe to our Newsletter'
-
description = attrs['description'] || 'Get the latest updates delivered to your inbox.'
-
button_text = attrs['button'] || 'Subscribe'
-
source = attrs['source'] || 'shortcode'
-
style = attrs['style'] || 'default'
-
-
<<~HTML
-
<div class="newsletter-form #{style}-style" data-controller="newsletter-form">
-
<div class="newsletter-header">
-
<h3 class="newsletter-title">#{title}</h3>
-
<p class="newsletter-description">#{description}</p>
-
</div>
-
-
<form action="/subscribe" method="post" class="newsletter-form-fields" data-action="submit->newsletter-form#submit">
-
<input type="hidden" name="authenticity_token" value="#{form_authenticity_token}">
-
<input type="hidden" name="source" value="#{source}">
-
-
<div class="form-group">
-
<input type="email"
-
name="subscriber[email]"
-
placeholder="Enter your email"
-
required
-
class="newsletter-email-input">
-
</div>
-
-
<div class="form-group">
-
<input type="text"
-
name="subscriber[name]"
-
placeholder="Your name (optional)"
-
class="newsletter-name-input">
-
</div>
-
-
<button type="submit" class="newsletter-submit-btn">
-
#{button_text}
-
</button>
-
-
<p class="newsletter-privacy">
-
We respect your privacy. Unsubscribe at any time.
-
</p>
-
</form>
-
</div>
-
-
<style>
-
.newsletter-form {
-
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-
color: white;
-
padding: 2rem;
-
border-radius: 12px;
-
max-width: 500px;
-
margin: 2rem auto;
-
}
-
.newsletter-form.minimal-style {
-
background: #f9fafb;
-
color: #1f2937;
-
border: 1px solid #e5e7eb;
-
}
-
.newsletter-title {
-
font-size: 1.5rem;
-
font-weight: bold;
-
margin-bottom: 0.5rem;
-
}
-
.newsletter-description {
-
opacity: 0.9;
-
margin-bottom: 1.5rem;
-
}
-
.newsletter-email-input,
-
.newsletter-name-input {
-
width: 100%;
-
padding: 0.75rem 1rem;
-
border: 1px solid rgba(255,255,255,0.3);
-
border-radius: 8px;
-
font-size: 1rem;
-
margin-bottom: 1rem;
-
}
-
.newsletter-form.minimal-style input {
-
border: 1px solid #e5e7eb;
-
background: white;
-
}
-
.newsletter-submit-btn {
-
width: 100%;
-
padding: 0.75rem 1rem;
-
background: white;
-
color: #667eea;
-
border: none;
-
border-radius: 8px;
-
font-weight: 600;
-
font-size: 1rem;
-
cursor: pointer;
-
transition: transform 0.2s;
-
}
-
.newsletter-form.minimal-style .newsletter-submit-btn {
-
background: #667eea;
-
color: white;
-
}
-
.newsletter-submit-btn:hover {
-
transform: translateY(-2px);
-
}
-
.newsletter-privacy {
-
margin-top: 1rem;
-
font-size: 0.875rem;
-
opacity: 0.7;
-
text-align: center;
-
}
-
</style>
-
HTML
-
end
-
-
def self.render_inline_form(attrs, content)
-
button_text = attrs['button'] || 'Subscribe'
-
source = attrs['source'] || 'inline_shortcode'
-
placeholder = attrs['placeholder'] || 'Enter your email'
-
-
<<~HTML
-
<div class="newsletter-inline-form" data-controller="newsletter-form">
-
<form action="/subscribe" method="post" class="inline-form" data-action="submit->newsletter-form#submit">
-
<input type="hidden" name="authenticity_token" value="#{form_authenticity_token}">
-
<input type="hidden" name="source" value="#{source}">
-
-
<div class="inline-form-wrapper">
-
<input type="email"
-
name="subscriber[email]"
-
placeholder="#{placeholder}"
-
required
-
class="inline-email-input">
-
<button type="submit" class="inline-submit-btn">
-
#{button_text}
-
</button>
-
</div>
-
</form>
-
</div>
-
-
<style>
-
.newsletter-inline-form {
-
margin: 2rem 0;
-
}
-
.inline-form-wrapper {
-
display: flex;
-
gap: 0.5rem;
-
max-width: 500px;
-
margin: 0 auto;
-
}
-
.inline-email-input {
-
flex: 1;
-
padding: 0.75rem 1rem;
-
border: 1px solid #e5e7eb;
-
border-radius: 8px;
-
font-size: 1rem;
-
}
-
.inline-submit-btn {
-
padding: 0.75rem 2rem;
-
background: #667eea;
-
color: white;
-
border: none;
-
border-radius: 8px;
-
font-weight: 600;
-
cursor: pointer;
-
white-space: nowrap;
-
}
-
.inline-submit-btn:hover {
-
background: #5568d3;
-
}
-
</style>
-
HTML
-
end
-
-
def self.render_popup_form(attrs, content)
-
# Popup newsletter form (requires JavaScript)
-
button_text = attrs['button'] || 'Subscribe'
-
trigger_text = attrs['trigger'] || 'Join Newsletter'
-
-
<<~HTML
-
<button class="newsletter-popup-trigger" data-action="click->newsletter-popup#open">
-
#{trigger_text}
-
</button>
-
-
<div class="newsletter-popup-overlay" data-newsletter-popup-target="overlay" style="display: none;">
-
<div class="newsletter-popup-modal">
-
<button class="newsletter-popup-close" data-action="click->newsletter-popup#close">×</button>
-
-
<h3 class="newsletter-popup-title">Subscribe to our Newsletter</h3>
-
<p class="newsletter-popup-description">Get the latest updates delivered to your inbox.</p>
-
-
<form action="/subscribe" method="post" data-action="submit->newsletter-popup#submit">
-
<input type="hidden" name="authenticity_token" value="#{form_authenticity_token}">
-
<input type="hidden" name="source" value="popup">
-
-
<input type="email" name="subscriber[email]" placeholder="Email" required class="newsletter-popup-input">
-
<input type="text" name="subscriber[name]" placeholder="Name (optional)" class="newsletter-popup-input">
-
-
<button type="submit" class="newsletter-popup-submit">#{button_text}</button>
-
</form>
-
</div>
-
</div>
-
-
<style>
-
.newsletter-popup-trigger {
-
padding: 0.75rem 1.5rem;
-
background: #667eea;
-
color: white;
-
border: none;
-
border-radius: 8px;
-
font-weight: 600;
-
cursor: pointer;
-
}
-
.newsletter-popup-overlay {
-
position: fixed;
-
top: 0;
-
left: 0;
-
right: 0;
-
bottom: 0;
-
background: rgba(0,0,0,0.7);
-
display: flex;
-
align-items: center;
-
justify-center;
-
z-index: 9999;
-
}
-
.newsletter-popup-modal {
-
background: white;
-
padding: 2rem;
-
border-radius: 12px;
-
max-width: 500px;
-
width: 90%;
-
position: relative;
-
}
-
.newsletter-popup-close {
-
position: absolute;
-
top: 1rem;
-
right: 1rem;
-
background: none;
-
border: none;
-
font-size: 2rem;
-
cursor: pointer;
-
color: #9ca3af;
-
}
-
.newsletter-popup-title {
-
font-size: 1.5rem;
-
font-weight: bold;
-
margin-bottom: 0.5rem;
-
color: #1f2937;
-
}
-
.newsletter-popup-description {
-
color: #6b7280;
-
margin-bottom: 1.5rem;
-
}
-
.newsletter-popup-input {
-
width: 100%;
-
padding: 0.75rem 1rem;
-
border: 1px solid #e5e7eb;
-
border-radius: 8px;
-
margin-bottom: 1rem;
-
}
-
.newsletter-popup-submit {
-
width: 100%;
-
padding: 0.75rem;
-
background: #667eea;
-
color: white;
-
border: none;
-
border-radius: 8px;
-
font-weight: 600;
-
cursor: pointer;
-
}
-
</style>
-
HTML
-
end
-
-
def self.render_stats(attrs)
-
stats = Subscriber.stats
-
-
<<~HTML
-
<div class="newsletter-stats">
-
<div class="stat-grid">
-
<div class="stat-card">
-
<div class="stat-value">#{number_with_delimiter(stats[:total])}</div>
-
<div class="stat-label">Total Subscribers</div>
-
</div>
-
<div class="stat-card">
-
<div class="stat-value">#{number_with_delimiter(stats[:confirmed])}</div>
-
<div class="stat-label">Confirmed</div>
-
</div>
-
<div class="stat-card">
-
<div class="stat-value">#{stats[:confirmation_rate]}%</div>
-
<div class="stat-label">Confirmation Rate</div>
-
</div>
-
</div>
-
</div>
-
-
<style>
-
.newsletter-stats {
-
margin: 2rem 0;
-
}
-
.stat-grid {
-
display: grid;
-
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
-
gap: 1rem;
-
}
-
.stat-card {
-
background: #f9fafb;
-
padding: 1.5rem;
-
border-radius: 8px;
-
text-align: center;
-
}
-
.stat-value {
-
font-size: 2rem;
-
font-weight: bold;
-
color: #667eea;
-
}
-
.stat-label {
-
font-size: 0.875rem;
-
color: #6b7280;
-
margin-top: 0.5rem;
-
}
-
</style>
-
HTML
-
end
-
-
def self.form_authenticity_token
-
# This would need to be passed from the view context
-
''
-
end
-
-
def self.number_with_delimiter(number)
-
number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
-
end
-
end
-
end
-
-
# Register shortcodes on load
-
Railspress::NewsletterShortcodes.register_all
-
-
-
-
-
-
-
-
-
module Railspress
-
module PluginApi
-
# Plugin API for accessing channels and overrides
-
module Channels
-
extend ActiveSupport::Concern
-
-
# Get all available channels
-
def self.all_channels
-
Channel.all
-
end
-
-
# Get active channels only
-
def self.active_channels
-
Channel.active
-
end
-
-
# Find channel by slug
-
def self.find_channel(slug)
-
Channel.find_by(slug: slug)
-
end
-
-
# Get channel for specific device type
-
def self.channel_for_device(device_type)
-
case device_type.to_s
-
when 'mobile', 'tablet'
-
Channel.find_by(slug: 'mobile')
-
when 'smart_tv', 'tv'
-
Channel.find_by(slug: 'smarttv')
-
when 'email'
-
Channel.find_by(slug: 'newsletter')
-
else
-
Channel.find_by(slug: 'web')
-
end
-
end
-
-
# Auto-detect channel from user agent
-
def self.auto_detect_channel(user_agent)
-
device_type = detect_device_type(user_agent)
-
channel_for_device(device_type)
-
end
-
-
# Get content with channel overrides applied
-
def self.content_with_overrides(content, channel_slug, resource_type, resource_id)
-
channel = find_channel(channel_slug)
-
return content unless channel
-
-
if content.respond_to?(:apply_channel_settings)
-
content.apply_channel_settings(content, user_agent)
-
else
-
# Apply basic channel overrides
-
channel.apply_overrides_to_data(content, resource_type, resource_id)
-
end
-
end
-
-
# Get channel-specific settings
-
def self.channel_settings(channel_slug)
-
channel = find_channel(channel_slug)
-
return {} unless channel
-
-
channel.settings.merge(
-
'channel_name' => channel.name,
-
'channel_slug' => channel.slug,
-
'domain' => channel.domain,
-
'locale' => channel.locale
-
)
-
end
-
-
# Check if content is excluded from channel
-
def self.is_excluded?(resource_type, resource_id, channel_slug)
-
channel = find_channel(channel_slug)
-
return false unless channel
-
-
channel.excluded?(resource_type, resource_id)
-
end
-
-
# Get all overrides for a channel
-
def self.channel_overrides(channel_slug)
-
channel = find_channel(channel_slug)
-
return [] unless channel
-
-
channel.channel_overrides.includes(:resource)
-
end
-
-
# Get overrides for specific resource
-
def self.resource_overrides(resource_type, resource_id, channel_slug)
-
channel = find_channel(channel_slug)
-
return [] unless channel
-
-
channel.overrides_for(resource_type, resource_id)
-
end
-
-
# Create a new channel override
-
def self.create_override(channel_slug, resource_type, resource_id, path, data, kind = 'override')
-
channel = find_channel(channel_slug)
-
return nil unless channel
-
-
channel.channel_overrides.create!(
-
resource_type: resource_type,
-
resource_id: resource_id,
-
path: path,
-
data: data,
-
kind: kind,
-
enabled: true
-
)
-
end
-
-
# Update channel settings
-
def self.update_channel_settings(channel_slug, settings)
-
channel = find_channel(channel_slug)
-
return false unless channel
-
-
channel.update!(settings: channel.settings.merge(settings))
-
end
-
-
private
-
-
def self.detect_device_type(user_agent)
-
return :email if user_agent.match?(/Outlook|Gmail|Apple Mail|Thunderbird|Mail|Yahoo Mail|Hotmail|AOL|Zimbra/i)
-
return :mobile if user_agent.match?(/iPhone|Android|Mobile|BlackBerry|Windows Phone|Opera Mini|IEMobile|webOS|Palm|Nokia/i)
-
return :tablet if user_agent.match?(/iPad|Android.*Tablet|Kindle|Silk|PlayBook|BB10|Tablet|Nexus 7|Nexus 10/i)
-
return :smart_tv if user_agent.match?(/SmartTV|TV|Roku|AppleTV|AndroidTV|WebOS|Tizen|NetCast|BRAVIA|Samsung|LG/i)
-
-
:desktop
-
end
-
end
-
end
-
end
-
1
module Railspress
-
1
class PluginBase
-
1
class << self
-
# DSL for plugin metadata (WordPress-style)
-
1
def plugin_name(name = nil)
-
then: 0
else: 0
@plugin_name = name if name
-
@plugin_name
-
end
-
-
1
def plugin_version(version = nil)
-
then: 0
else: 0
@plugin_version = version if version
-
@plugin_version
-
end
-
-
1
def plugin_description(description = nil)
-
then: 0
else: 0
@plugin_description = description if description
-
@plugin_description
-
end
-
-
1
def plugin_author(author = nil)
-
then: 0
else: 0
@plugin_author = author if author
-
@plugin_author
-
end
-
-
1
def plugin_url(url = nil)
-
then: 0
else: 0
@plugin_url = url if url
-
@plugin_url
-
end
-
-
1
def plugin_license(license = nil)
-
then: 0
else: 0
@plugin_license = license if license
-
@plugin_license
-
end
-
-
# DSL method for defining settings schema
-
1
def settings_schema(&block)
-
@settings_schema_block = block
-
end
-
end
-
-
1
attr_reader :settings_schema, :admin_pages, :routes_block
-
-
1
def initialize
-
@settings_schema = []
-
@admin_pages = []
-
@routes_block = nil
-
@admin_routes_block = nil
-
@frontend_routes_block = nil
-
-
# Enhanced plugin features
-
@webhooks = []
-
@events = []
-
@middleware = []
-
@assets = []
-
@commands = []
-
@validators = []
-
@api_endpoints = []
-
@theme_templates = []
-
@theme_assets = []
-
@theme_settings = []
-
-
# Copy class-level metadata to instance
-
@name = self.class.plugin_name
-
@version = self.class.plugin_version
-
@description = self.class.plugin_description
-
@author = self.class.plugin_author
-
@url = self.class.plugin_url
-
@license = self.class.plugin_license
-
-
# Execute settings schema block if defined
-
then: 0
else: 0
if self.class.instance_variable_get(:@settings_schema_block)
-
instance_eval(&self.class.instance_variable_get(:@settings_schema_block))
-
end
-
-
then: 0
else: 0
setup if respond_to?(:setup, true)
-
end
-
-
# Accessors for metadata
-
1
def name
-
@name || self.class.name.demodulize
-
end
-
-
1
def version
-
@version || '1.0.0'
-
end
-
-
1
def description
-
@description || ''
-
end
-
-
1
def author
-
@author || ''
-
end
-
-
# Activation hook - called when plugin is activated
-
1
def activate
-
log("Activating #{name} v#{version}")
-
# Override in subclass
-
end
-
-
# Deactivation hook - called when plugin is deactivated
-
1
def deactivate
-
log("Deactivating #{name}")
-
# Override in subclass
-
end
-
-
# Uninstall hook - called when plugin is deleted
-
1
def uninstall
-
log("Uninstalling #{name}")
-
# Remove all plugin settings
-
PluginSetting.where(plugin_name: plugin_identifier).destroy_all
-
# Override in subclass for additional cleanup
-
end
-
-
# ========================================
-
# CHANNEL INTEGRATION
-
# ========================================
-
-
# Get all available channels
-
1
def all_channels
-
Channel.all
-
end
-
-
# Get active channels only
-
1
def active_channels
-
Channel.active
-
end
-
-
# Find channel by slug
-
1
def find_channel(slug)
-
Channel.find_by(slug: slug)
-
end
-
-
# Get channel for specific device type
-
1
def channel_for_device(device_type)
-
case device_type.to_s
-
when: 0
when 'mobile', 'tablet'
-
Channel.find_by(slug: 'mobile')
-
when: 0
when 'smart_tv', 'tv'
-
Channel.find_by(slug: 'smarttv')
-
when: 0
when 'email'
-
Channel.find_by(slug: 'newsletter')
-
else: 0
else
-
Channel.find_by(slug: 'web')
-
end
-
end
-
-
# Auto-detect channel from user agent
-
1
def auto_detect_channel(user_agent)
-
device_type = detect_device_type(user_agent)
-
channel_for_device(device_type)
-
end
-
-
# Get content with channel overrides applied
-
1
def content_with_overrides(content, channel_slug, resource_type, resource_id)
-
channel = find_channel(channel_slug)
-
else: 0
then: 0
return content unless channel
-
-
then: 0
if content.respond_to?(:apply_channel_settings)
-
content.apply_channel_settings(content, user_agent)
-
else
-
else: 0
# Apply basic channel overrides
-
channel.apply_overrides_to_data(content, resource_type, resource_id)
-
end
-
end
-
-
# Get channel-specific settings
-
1
def channel_settings(channel_slug)
-
channel = find_channel(channel_slug)
-
else: 0
then: 0
return {} unless channel
-
-
channel.settings.merge(
-
'channel_name' => channel.name,
-
'channel_slug' => channel.slug,
-
'domain' => channel.domain,
-
'locale' => channel.locale
-
)
-
end
-
-
# Check if content is excluded from channel
-
1
def is_excluded?(resource_type, resource_id, channel_slug)
-
channel = find_channel(channel_slug)
-
else: 0
then: 0
return false unless channel
-
-
channel.excluded?(resource_type, resource_id)
-
end
-
-
# Get all overrides for a channel
-
1
def channel_overrides(channel_slug)
-
channel = find_channel(channel_slug)
-
else: 0
then: 0
return [] unless channel
-
-
channel.channel_overrides.includes(:resource)
-
end
-
-
# Get overrides for specific resource
-
1
def resource_overrides(resource_type, resource_id, channel_slug)
-
channel = find_channel(channel_slug)
-
else: 0
then: 0
return [] unless channel
-
-
channel.overrides_for(resource_type, resource_id)
-
end
-
-
# Create a new channel override
-
1
def create_override(channel_slug, resource_type, resource_id, path, data, kind = 'override')
-
channel = find_channel(channel_slug)
-
else: 0
then: 0
return nil unless channel
-
-
channel.channel_overrides.create!(
-
resource_type: resource_type,
-
resource_id: resource_id,
-
path: path,
-
data: data,
-
kind: kind,
-
enabled: true
-
)
-
end
-
-
# Update channel settings
-
1
def update_channel_settings(channel_slug, settings)
-
channel = find_channel(channel_slug)
-
else: 0
then: 0
return false unless channel
-
-
channel.update!(settings: channel.settings.merge(settings))
-
end
-
-
# Process content for specific channel
-
1
def process_content_for_channel(content, channel_slug, options = {})
-
settings = channel_settings(channel_slug)
-
processed_content = content.dup
-
-
# Apply device-specific optimizations
-
case settings['device_type']
-
when: 0
when 'mobile', 'tablet'
-
processed_content = optimize_for_mobile(processed_content, settings)
-
when: 0
when 'smart_tv'
-
processed_content = optimize_for_tv(processed_content, settings)
-
when: 0
when 'email'
-
processed_content = optimize_for_email(processed_content, settings)
-
else: 0
else
-
processed_content = optimize_for_desktop(processed_content, settings)
-
end
-
-
# Apply channel overrides
-
then: 0
else: 0
if options[:apply_overrides] != false
-
processed_content = content_with_overrides(processed_content, channel_slug, options[:resource_type], options[:resource_id])
-
end
-
-
processed_content
-
end
-
-
# Distribute content to all channels
-
1
def distribute_content_to_channels(content, options = {})
-
results = {}
-
-
Channel.active.each do |channel|
-
results[channel.slug] = process_content_for_channel(
-
content,
-
channel.slug,
-
options.merge(
-
resource_type: options[:resource_type],
-
resource_id: options[:resource_id]
-
)
-
)
-
end
-
-
results
-
end
-
-
# Check if plugin has settings
-
1
def has_settings?
-
@settings_schema.any?
-
end
-
-
# Get all admin pages for this plugin
-
1
def admin_pages
-
@admin_pages
-
end
-
-
# Check if plugin has admin pages
-
1
def has_admin_pages?
-
@admin_pages.any?
-
end
-
-
# Get plugin setting value
-
1
def get_setting(key, default = nil)
-
setting = PluginSetting.find_by(plugin_name: plugin_identifier, key: key.to_s)
-
then: 0
else: 0
return parse_setting_value(setting.value, setting.setting_type) if setting
-
-
# Return default from schema
-
schema_setting = @settings_schema.find { |s| s[:key] == key.to_s }
-
then: 0
else: 0
schema_setting&.dig(:default) || default
-
end
-
-
# Set plugin setting value
-
1
def set_setting(key, value)
-
# Determine setting type from schema
-
schema = @settings_schema.find { |s| s[:key] == key.to_s }
-
then: 0
else: 0
setting_type = schema ? map_schema_type_to_db_type(schema[:type]) : 'string'
-
-
PluginSetting.find_or_create_by!(
-
plugin_name: plugin_identifier,
-
key: key.to_s
-
) do |setting|
-
setting.value = value.to_s
-
setting.setting_type = setting_type
-
end.tap do |setting|
-
setting.update(value: value.to_s, setting_type: setting_type)
-
end
-
end
-
-
# Get all plugin settings as hash
-
1
def get_all_settings
-
hash = {}
-
@settings_schema.each do |schema|
-
hash[schema[:key]] = get_setting(schema[:key])
-
end
-
hash
-
end
-
-
# Update multiple settings at once
-
1
def update_settings(settings_hash)
-
settings_hash.each do |key, value|
-
set_setting(key, value)
-
end
-
end
-
-
1
private
-
-
1
def detect_device_type(user_agent)
-
then: 0
else: 0
return :email if user_agent.match?(/Outlook|Gmail|Apple Mail|Thunderbird|Mail|Yahoo Mail|Hotmail|AOL|Zimbra/i)
-
then: 0
else: 0
return :mobile if user_agent.match?(/iPhone|Android|Mobile|BlackBerry|Windows Phone|Opera Mini|IEMobile|webOS|Palm|Nokia/i)
-
then: 0
else: 0
return :tablet if user_agent.match?(/iPad|Android.*Tablet|Kindle|Silk|PlayBook|BB10|Tablet|Nexus 7|Nexus 10/i)
-
then: 0
else: 0
return :smart_tv if user_agent.match?(/SmartTV|TV|Roku|AppleTV|AndroidTV|WebOS|Tizen|NetCast|BRAVIA|Samsung|LG/i)
-
-
:desktop
-
end
-
-
1
def optimize_for_mobile(content, settings)
-
then: 0
else: 0
content.gsub(/<iframe[^>]*>/i, '') # Remove iframes
-
.gsub(/width="\d+"/i, '') # Remove width attributes
-
.gsub(/height="\d+"/i, '') # Remove height attributes
-
.gsub(/<script[^>]*>.*?<\/script>/mi, '') if settings['minimal_js']
-
end
-
-
1
def optimize_for_tv(content, settings)
-
content.gsub(/<img([^>]*)>/i, '<img\1 style="max-width: 100%; height: auto;">')
-
.gsub(/font-size:\s*\d+px/i, "font-size: #{settings['font_size'] || '24px'}") # Larger text
-
end
-
-
1
def optimize_for_email(content, settings)
-
content.gsub(/style="[^"]*"/i, '') # Remove inline styles
-
.gsub(/<div([^>]*)>/i, '<table><tr><td\1>') # Convert divs to tables
-
.gsub(/<\/div>/i, '</td></tr></table>')
-
end
-
-
1
def optimize_for_desktop(content, settings)
-
content
-
end
-
-
# ========================================
-
# SETTINGS SYSTEM
-
# ========================================
-
-
# Define plugin setting with schema
-
# Example:
-
# define_setting :api_key,
-
# type: 'string',
-
# default: '',
-
# label: 'API Key',
-
# description: 'Your API key',
-
# required: true,
-
# placeholder: 'sk-...'
-
1
def define_setting(key, options = {})
-
@settings_schema << {
-
key: key.to_s,
-
type: options[:type] || 'string',
-
default: options[:default],
-
label: options[:label] || key.to_s.titleize,
-
description: options[:description],
-
required: options[:required] || false,
-
options: options[:options], # For select/radio types
-
placeholder: options[:placeholder],
-
min: options[:min],
-
max: options[:max],
-
rows: options[:rows], # For textarea
-
group: options[:group] # For organizing settings
-
}
-
end
-
-
# ========================================
-
# SETTINGS SCHEMA DSL METHODS
-
# ========================================
-
-
# Define a settings section
-
1
def section(title, options = {}, &block)
-
# For now, we'll just execute the block
-
# In a more advanced implementation, we could group settings by section
-
then: 0
else: 0
instance_eval(&block) if block_given?
-
end
-
-
# Define a text input setting
-
1
def text(key, label, options = {})
-
define_setting(key, {
-
type: 'text',
-
label: label,
-
description: options[:description],
-
required: options[:required] || false,
-
placeholder: options[:placeholder],
-
default: options[:default]
-
})
-
end
-
-
# Define a textarea setting
-
1
def textarea(key, label, options = {})
-
define_setting(key, {
-
type: 'textarea',
-
label: label,
-
description: options[:description],
-
required: options[:required] || false,
-
placeholder: options[:placeholder],
-
default: options[:default],
-
rows: options[:rows] || 4
-
})
-
end
-
-
# Define a select dropdown setting
-
1
def select(key, label, options_array, options = {})
-
define_setting(key, {
-
type: 'select',
-
label: label,
-
description: options[:description],
-
required: options[:required] || false,
-
default: options[:default],
-
options: options_array
-
})
-
end
-
-
# Define a checkbox setting
-
1
def checkbox(key, label, options = {})
-
define_setting(key, {
-
type: 'checkbox',
-
label: label,
-
description: options[:description],
-
required: options[:required] || false,
-
default: options[:default] || false
-
})
-
end
-
-
# Define a number input setting
-
1
def number(key, label, options = {})
-
define_setting(key, {
-
type: 'number',
-
label: label,
-
description: options[:description],
-
required: options[:required] || false,
-
default: options[:default],
-
min: options[:min],
-
max: options[:max]
-
})
-
end
-
-
# Define a URL input setting
-
1
def url(key, label, options = {})
-
define_setting(key, {
-
type: 'url',
-
label: label,
-
description: options[:description],
-
required: options[:required] || false,
-
placeholder: options[:placeholder],
-
default: options[:default]
-
})
-
end
-
-
# Define a color picker setting
-
1
def color(key, label, options = {})
-
define_setting(key, {
-
type: 'color',
-
label: label,
-
description: options[:description],
-
required: options[:required] || false,
-
default: options[:default]
-
})
-
end
-
-
# Define a radio button setting
-
1
def radio(key, label, options_array, options = {})
-
define_setting(key, {
-
type: 'radio',
-
label: label,
-
description: options[:description],
-
required: options[:required] || false,
-
default: options[:default],
-
options: options_array
-
})
-
end
-
-
# Register a UI block (placeholder method)
-
1
def register_block(block_name, options = {})
-
# This is a placeholder for UI block registration
-
# In a full implementation, this would register blocks for the theme system
-
Rails.logger.info "Registered UI block: #{block_name}"
-
end
-
-
# Add an action hook (similar to WordPress add_action)
-
1
def add_action(hook_name, callback = nil, priority = 10, &block)
-
then: 0
else: 0
if block_given?
-
callback = block
-
end
-
-
then: 0
if callback
-
Railspress::PluginSystem.add_action(hook_name, callback, priority, plugin_name)
-
Rails.logger.info "Added action hook: #{hook_name} for plugin: #{plugin_name}"
-
else: 0
else
-
Rails.logger.warn "No callback provided for hook: #{hook_name}"
-
end
-
end
-
-
# Check if setting is enabled (for boolean settings)
-
1
def setting_enabled?(key)
-
value = get_setting(key)
-
value == true || value == 'true' || value == '1'
-
end
-
-
# ========================================
-
# ADMIN PAGES SYSTEM
-
# ========================================
-
-
# Register an admin page for this plugin
-
# Example:
-
# register_admin_page(
-
# slug: 'dashboard',
-
# title: 'My Plugin Dashboard',
-
# menu_title: 'Dashboard',
-
# icon: 'chart-bar',
-
# position: 10,
-
# parent: 'plugins' # Optional parent menu
-
# )
-
1
def register_admin_page(options = {})
-
page = {
-
plugin: plugin_identifier,
-
slug: options[:slug] || 'settings',
-
path: "admin/plugins/#{plugin_identifier}/#{options[:slug] || 'settings'}",
-
title: options[:title] || "#{name} Settings",
-
menu_title: options[:menu_title] || name,
-
capability: options[:capability] || 'administrator',
-
icon: options[:icon] || 'puzzle',
-
position: options[:position] || 100,
-
parent: options[:parent], # 'plugins', 'tools', 'settings', or nil for top-level
-
callback: options[:callback] # Method to call for rendering
-
}
-
-
@admin_pages << page
-
-
# Store in global registry for sidebar rendering
-
Railspress::PluginSystem.register_admin_page(plugin_identifier, page)
-
-
log("Registered admin page: #{page[:path]}")
-
end
-
-
# Render default settings page
-
1
def render_settings_page
-
{
-
title: "#{name} Settings",
-
settings: @settings_schema,
-
current_values: get_all_settings,
-
save_url: "/admin/plugins/#{plugin_identifier}/settings",
-
plugin_info: metadata
-
}
-
end
-
-
# ========================================
-
# ROUTES SYSTEM
-
# ========================================
-
-
# Register routes for this plugin
-
# Example:
-
# register_routes do
-
# get '/my-plugin/action', to: 'my_plugin#action'
-
# namespace :admin do
-
# resources :my_plugin
-
# end
-
# end
-
1
def register_routes(&block)
-
@routes_block = block
-
Railspress::PluginSystem.register_plugin_routes(plugin_identifier, block)
-
log("Routes registered for #{name}", :debug)
-
end
-
-
# Register admin routes for this plugin
-
1
def register_admin_routes(&block)
-
@admin_routes_block = block
-
Railspress::PluginSystem.register_plugin_admin_routes(plugin_identifier, block)
-
log("Admin routes registered for #{name}", :debug)
-
end
-
-
# Register frontend routes for this plugin
-
1
def register_frontend_routes(&block)
-
@frontend_routes_block = block
-
Railspress::PluginSystem.register_plugin_frontend_routes(plugin_identifier, block)
-
log("Frontend routes registered for #{name}", :debug)
-
end
-
-
# Check if plugin has routes
-
1
def has_routes?
-
@routes_block.present? || @admin_routes_block.present? || @frontend_routes_block.present?
-
end
-
-
# ========================================
-
# WEBHOOK SYSTEM
-
# ========================================
-
-
# Register a webhook endpoint
-
1
def register_webhook(event_name, url, options = {})
-
webhook = {
-
event: event_name,
-
url: url,
-
method: options[:method] || 'POST',
-
headers: options[:headers] || {},
-
secret: options[:secret],
-
retry_count: options[:retry_count] || 3,
-
timeout: options[:timeout] || 30,
-
active: options[:active] != false
-
}
-
-
@webhooks << webhook
-
Railspress::PluginSystem.register_webhook(plugin_identifier, webhook)
-
log("Registered webhook for event: #{event_name}", :debug)
-
end
-
-
# Trigger a webhook
-
1
def trigger_webhook(event_name, data = {})
-
Railspress::PluginSystem.trigger_webhook(plugin_identifier, event_name, data)
-
end
-
-
# ========================================
-
# EVENT SYSTEM
-
# ========================================
-
-
# Register an event listener
-
1
def on(event_name, &block)
-
event = {
-
name: event_name,
-
callback: block,
-
priority: 10
-
}
-
-
@events << event
-
Railspress::PluginSystem.register_event_listener(plugin_identifier, event)
-
log("Registered event listener for: #{event_name}", :debug)
-
end
-
-
# Emit an event
-
1
def emit(event_name, data = {})
-
Railspress::PluginSystem.emit_event(event_name, data)
-
end
-
-
# ========================================
-
# MIDDLEWARE SYSTEM
-
# ========================================
-
-
# Add middleware to the application
-
1
def add_middleware(middleware_class, *args, &block)
-
middleware = {
-
class: middleware_class,
-
args: args,
-
block: block
-
}
-
-
@middleware << middleware
-
Railspress::PluginSystem.register_middleware(plugin_identifier, middleware)
-
log("Registered middleware: #{middleware_class}", :debug)
-
end
-
-
# ========================================
-
# ASSET MANAGEMENT
-
# ========================================
-
-
# Register plugin assets (CSS, JS, images)
-
1
def register_asset(path, type = :javascript, options = {})
-
asset = {
-
path: path,
-
type: type,
-
admin_only: options[:admin_only] || false,
-
frontend_only: options[:frontend_only] || false,
-
priority: options[:priority] || 10,
-
dependencies: options[:dependencies] || []
-
}
-
-
@assets << asset
-
Railspress::PluginSystem.register_asset(plugin_identifier, asset)
-
log("Registered #{type} asset: #{path}", :debug)
-
end
-
-
# Register CSS asset
-
1
def register_stylesheet(path, options = {})
-
register_asset(path, :stylesheet, options)
-
end
-
-
# Register JavaScript asset
-
1
def register_javascript(path, options = {})
-
register_asset(path, :javascript, options)
-
end
-
-
# Register image asset
-
1
def register_image(path, options = {})
-
register_asset(path, :image, options)
-
end
-
-
# ========================================
-
# API ENDPOINTS
-
# ========================================
-
-
# Register API endpoint
-
1
def register_api_endpoint(method, path, controller_action, options = {})
-
endpoint = {
-
method: method.to_s.upcase,
-
path: path,
-
controller: controller_action[:controller],
-
action: controller_action[:action],
-
authentication: options[:authentication] || :token,
-
rate_limit: options[:rate_limit],
-
version: options[:version] || 'v1'
-
}
-
-
@api_endpoints << endpoint
-
Railspress::PluginSystem.register_api_endpoint(plugin_identifier, endpoint)
-
log("Registered API endpoint: #{method.upcase} #{path}", :debug)
-
end
-
-
# ========================================
-
# THEME SYSTEM
-
# ========================================
-
-
# Register theme template
-
1
def register_theme_template(name, content, options = {})
-
template = {
-
name: name,
-
content: content,
-
type: options[:type] || :page,
-
theme: options[:theme] || 'default',
-
variables: options[:variables] || []
-
}
-
-
@theme_templates << template
-
Railspress::PluginSystem.register_theme_template(plugin_identifier, template)
-
log("Registered theme template: #{name}", :debug)
-
end
-
-
# Register theme asset
-
1
def register_theme_asset(path, type, options = {})
-
asset = {
-
path: path,
-
type: type,
-
theme: options[:theme] || 'default',
-
public: options[:public] || false
-
}
-
-
@theme_assets << asset
-
Railspress::PluginSystem.register_theme_asset(plugin_identifier, asset)
-
log("Registered theme asset: #{path}", :debug)
-
end
-
-
# Register theme setting
-
1
def register_theme_setting(key, type, options = {})
-
setting = {
-
key: key,
-
type: type,
-
default: options[:default],
-
label: options[:label],
-
description: options[:description],
-
theme: options[:theme] || 'default'
-
}
-
-
@theme_settings << setting
-
Railspress::PluginSystem.register_theme_setting(plugin_identifier, setting)
-
log("Registered theme setting: #{key}", :debug)
-
end
-
-
# ========================================
-
# CUSTOM VALIDATORS
-
# ========================================
-
-
# Register custom validator
-
1
def register_validator(name, &block)
-
validator = {
-
name: name,
-
block: block
-
}
-
-
@validators << validator
-
Railspress::PluginSystem.register_validator(plugin_identifier, validator)
-
log("Registered custom validator: #{name}", :debug)
-
end
-
-
# ========================================
-
# CUSTOM COMMANDS
-
# ========================================
-
-
# Register custom rake task
-
1
def register_command(name, description, &block)
-
command = {
-
name: name,
-
description: description,
-
block: block
-
}
-
-
@commands << command
-
Railspress::PluginSystem.register_command(plugin_identifier, command)
-
log("Registered custom command: #{name}", :debug)
-
end
-
-
# ========================================
-
# CACHE SYSTEM
-
# ========================================
-
-
# Cache data with plugin-specific key
-
1
def cache(key, data = nil, expires_in: 1.hour)
-
cache_key = "#{plugin_identifier}:#{key}"
-
-
then: 0
if data
-
Rails.cache.write(cache_key, data, expires_in: expires_in)
-
data
-
else: 0
else
-
Rails.cache.read(cache_key)
-
end
-
end
-
-
# Clear plugin cache
-
1
def clear_cache(pattern = nil)
-
then: 0
if pattern
-
Rails.cache.delete_matched("#{plugin_identifier}:#{pattern}")
-
else: 0
else
-
Rails.cache.delete_matched("#{plugin_identifier}:*")
-
end
-
end
-
-
# ========================================
-
# NOTIFICATION SYSTEM
-
# ========================================
-
-
# Send notification to admin users
-
1
def notify_admin(message, type = :info, options = {})
-
Railspress::PluginSystem.notify_admin(plugin_identifier, message, type, options)
-
end
-
-
# Send notification to specific user
-
1
def notify_user(user_id, message, type = :info, options = {})
-
Railspress::PluginSystem.notify_user(plugin_identifier, user_id, message, type, options)
-
end
-
-
# ========================================
-
# SCHEDULER SYSTEM
-
# ========================================
-
-
# Schedule a recurring task
-
1
def schedule_task(name, cron_expression, &block)
-
task = {
-
name: name,
-
cron: cron_expression,
-
block: block
-
}
-
-
Railspress::PluginSystem.schedule_task(plugin_identifier, task)
-
log("Scheduled task: #{name} (#{cron_expression})", :debug)
-
end
-
-
# ========================================
-
# DATABASE HELPERS
-
# ========================================
-
-
# Create table for plugin
-
1
def create_table(table_name, &block)
-
migration = Railspress::PluginSystem.create_plugin_migration(plugin_identifier, table_name, &block)
-
log("Created table migration: #{table_name}", :debug)
-
migration
-
end
-
-
# Add column to existing table
-
1
def add_column(table_name, column_name, type, options = {})
-
Railspress::PluginSystem.add_plugin_column(plugin_identifier, table_name, column_name, type, options)
-
log("Added column: #{table_name}.#{column_name}", :debug)
-
end
-
-
# ========================================
-
# UTILITY METHODS
-
# ========================================
-
-
# Get plugin root path
-
1
def plugin_path
-
@plugin_path ||= Rails.root.join('lib', 'plugins', plugin_identifier)
-
end
-
-
# Get plugin public URL
-
1
def plugin_url(path = '')
-
"/plugins/#{plugin_identifier}/#{path}".gsub(/\/+/, '/')
-
end
-
-
# Get plugin admin URL
-
1
def admin_url(path = '')
-
"/admin/#{plugin_identifier}/#{path}".gsub(/\/+/, '/')
-
end
-
-
# Check if feature is enabled
-
1
def feature_enabled?(feature_name)
-
get_setting("feature_#{feature_name}", false)
-
end
-
-
# Enable/disable feature
-
1
def set_feature(feature_name, enabled)
-
set_setting("feature_#{feature_name}", enabled)
-
end
-
-
# ========================================
-
# HOOKS & FILTERS
-
# ========================================
-
-
-
# Add a filter hook
-
1
def add_filter(filter_name, method_name, priority = 10)
-
Railspress::PluginSystem.add_filter(filter_name, -> (value, *args) {
-
self.send(method_name, value, *args)
-
}, priority)
-
end
-
-
# ========================================
-
# BACKGROUND JOBS
-
# ========================================
-
-
# Create a background job for the plugin
-
1
def create_job(job_name, &block)
-
job_class_name = "#{plugin_identifier.camelize}::#{job_name}"
-
-
# Define job class dynamically
-
job_class = Class.new(ApplicationJob) do
-
queue_as :default
-
then: 0
else: 0
class_eval(&block) if block_given?
-
end
-
-
# Set constant in plugin module
-
plugin_module_name = plugin_identifier.camelize
-
else: 0
then: 0
unless Object.const_defined?(plugin_module_name)
-
Object.const_set(plugin_module_name, Module.new)
-
end
-
plugin_module = plugin_module_name.constantize
-
plugin_module.const_set(job_name, job_class)
-
-
log("Created job: #{job_class_name}")
-
job_class
-
end
-
-
# Enqueue a job to run immediately
-
1
def enqueue_job(job_class, *args)
-
job_class.perform_later(*args)
-
log("Enqueued job: #{job_class.name}")
-
end
-
-
# Schedule a job to run at specific time
-
1
def schedule_job(job_class, run_at, *args)
-
job_class.set(wait_until: run_at).perform_later(*args)
-
log("Scheduled job: #{job_class.name} at #{run_at}")
-
end
-
-
# Schedule a job to run after delay
-
1
def schedule_job_in(job_class, delay, *args)
-
job_class.set(wait: delay).perform_later(*args)
-
log("Scheduled job: #{job_class.name} in #{delay}")
-
end
-
-
# Schedule recurring job (using Sidekiq-cron if available)
-
1
def schedule_recurring_job(job_name, cron_expression, job_class, *args)
-
else: 0
then: 0
return unless defined?(Sidekiq::Cron)
-
-
Sidekiq::Cron::Job.create(
-
name: "#{plugin_identifier}_#{job_name}",
-
cron: cron_expression,
-
class: job_class.name,
-
args: args.to_json
-
)
-
-
log("Scheduled recurring job: #{job_name} (#{cron_expression})")
-
end
-
-
# Remove recurring job
-
1
def remove_recurring_job(job_name)
-
else: 0
then: 0
return unless defined?(Sidekiq::Cron)
-
-
Sidekiq::Cron::Job.destroy("#{plugin_identifier}_#{job_name}")
-
log("Removed recurring job: #{job_name}")
-
end
-
-
# Get all recurring jobs for this plugin
-
1
def recurring_jobs
-
else: 0
then: 0
return [] unless defined?(Sidekiq::Cron)
-
-
prefix = "#{plugin_identifier}_"
-
Sidekiq::Cron::Job.all.select { |job| job.name.start_with?(prefix) }
-
end
-
-
# ========================================
-
# UTILITY METHODS
-
# ========================================
-
-
# Get plugin name (instance method)
-
1
def plugin_name
-
self.class.plugin_name
-
end
-
-
# Get plugin identifier (snake_case name)
-
1
def plugin_identifier
-
plugin_name.underscore.gsub(/\s+/, '_').gsub(/[^a-z0-9_]/, '')
-
end
-
-
# Get plugin directory path
-
1
def plugin_path
-
Rails.root.join('lib', 'plugins', plugin_identifier)
-
end
-
-
# Load a plugin view
-
1
def plugin_view(view_name)
-
"plugins/#{plugin_identifier}/#{view_name}"
-
end
-
-
# Plugin asset URL
-
1
def plugin_asset_url(asset_name)
-
"/plugins/#{plugin_identifier}/assets/#{asset_name}"
-
end
-
-
# Log plugin message
-
1
def log(message, level = :info)
-
Rails.logger.send(level, "[#{name}] #{message}")
-
end
-
-
# Check if plugin meets requirements
-
1
def check_requirements
-
# Override in subclass to check dependencies
-
# Return array of error messages, or empty array if all OK
-
[]
-
end
-
-
# Plugin metadata for display
-
1
def metadata
-
{
-
name: name,
-
version: version,
-
description: description,
-
author: author,
-
url: @url,
-
license: @license,
-
identifier: plugin_identifier,
-
has_settings: has_settings?,
-
has_admin_pages: has_admin_pages?,
-
settings_count: @settings_schema.length,
-
admin_pages_count: @admin_pages.length
-
}
-
end
-
-
# ========================================
-
# CONTENT TYPE HELPERS
-
# ========================================
-
-
# Get all active content types
-
1
def get_content_types
-
ContentType.active.ordered
-
end
-
-
# Get a specific content type by identifier
-
1
def get_content_type(ident)
-
ContentType.find_by_ident(ident)
-
end
-
-
# Register a new content type
-
1
def register_content_type(ident, options = {})
-
ContentType.find_or_create_by!(ident: ident) do |ct|
-
ct.label = options[:label] || ident.titleize
-
ct.singular = options[:singular] || ct.label
-
ct.plural = options[:plural] || ct.label.pluralize
-
ct.description = options[:description]
-
ct.icon = options[:icon] || 'document-text'
-
ct.public = options.fetch(:public, true)
-
ct.hierarchical = options.fetch(:hierarchical, false)
-
ct.has_archive = options.fetch(:has_archive, true)
-
ct.menu_position = options[:menu_position]
-
ct.supports = options[:supports] || ['title', 'editor', 'excerpt', 'thumbnail']
-
ct.capabilities = options[:capabilities] || {}
-
ct.rest_base = options[:rest_base]
-
ct.active = options.fetch(:active, true)
-
end
-
-
log("Registered content type: #{ident}", :debug)
-
end
-
-
# Unregister a content type (marks as inactive)
-
1
def unregister_content_type(ident)
-
ct = ContentType.find_by_ident(ident)
-
then: 0
else: 0
if ct
-
ct.update(active: false)
-
log("Unregistered content type: #{ident}", :debug)
-
end
-
end
-
-
# Get posts of a specific content type
-
1
def get_posts_by_type(ident, limit: nil)
-
ct = get_content_type(ident)
-
else: 0
then: 0
return Post.none unless ct
-
-
posts = Post.where(content_type: ct)
-
then: 0
else: 0
posts = posts.limit(limit) if limit
-
posts
-
end
-
-
# ========================================
-
# UPLOAD SYSTEM
-
# ========================================
-
-
# Upload a file securely
-
1
def upload_file(file, options = {})
-
upload = Upload.new(
-
title: options[:title] || file.original_filename,
-
description: options[:description],
-
alt_text: options[:alt_text]
-
)
-
upload.file.attach(file)
-
then: 0
else: 0
upload.user = current_user if respond_to?(:current_user)
-
upload.storage_provider = StorageProvider.active.first
-
-
# Security validation
-
security = UploadSecurity.current
-
else: 0
then: 0
unless security.file_allowed?(file)
-
raise "File not allowed: #{file.original_filename}"
-
end
-
-
# Check for suspicious files
-
then: 0
else: 0
if security.file_suspicious?(file)
-
then: 0
if security.quarantine_suspicious?
-
upload.quarantined = true
-
upload.quarantine_reason = 'Suspicious file pattern detected'
-
else: 0
else
-
raise "File rejected: #{file.original_filename} appears suspicious"
-
end
-
end
-
-
upload.save!
-
upload
-
end
-
-
# Get approved uploads
-
1
def get_uploads(options = {})
-
uploads = Upload.approved
-
then: 0
else: 0
uploads = uploads.where(user: options[:user]) if options[:user]
-
then: 0
else: 0
uploads = uploads.where("title LIKE ?", "%#{options[:search]}%") if options[:search]
-
then: 0
else: 0
uploads = uploads.limit(options[:limit]) if options[:limit]
-
then: 0
else: 0
uploads = uploads.offset(options[:offset]) if options[:offset]
-
uploads
-
end
-
-
# Get quarantined uploads
-
1
def get_quarantined_uploads
-
Upload.quarantined
-
end
-
-
# Approve a quarantined upload
-
1
def approve_upload(upload)
-
upload.approve!
-
end
-
-
# Reject a quarantined upload
-
1
def reject_upload(upload)
-
upload.reject!
-
end
-
-
# Parse setting value based on type
-
1
def parse_setting_value(value, type)
-
case type
-
when: 0
when 'boolean'
-
value == 'true' || value == '1' || value == true
-
when: 0
when 'integer', 'number'
-
value.to_i
-
when: 0
when 'float'
-
value.to_f
-
when: 0
when 'array', 'json'
-
JSON.parse(value) rescue []
-
else: 0
else
-
value
-
end
-
end
-
end
-
end
-
# frozen_string_literal: true
-
-
module Railspress
-
# Plugin Blocks System - Similar to Shopify App Blocks
-
# Allows plugins to inject custom UI blocks into admin pages (posts, pages, etc.)
-
class PluginBlocks
-
@blocks = {}
-
-
class << self
-
# Register a new block
-
#
-
# @param key [Symbol] Unique identifier for the block
-
# @param options [Hash] Block configuration
-
# @option options [String] :label Display name for the block
-
# @option options [String] :description Block description
-
# @option options [String] :icon SVG icon or icon class
-
# @option options [Array<Symbol>] :locations Where the block can appear (:post, :page, :product, etc.)
-
# @option options [String] :position Block position (:sidebar, :main, :footer, :header)
-
# @option options [Integer] :order Display order (lower numbers appear first)
-
# @option options [String] :partial Path to the partial to render
-
# @option options [Proc] :render_proc Alternative to partial - a proc that renders the block
-
# @option options [Hash] :settings Block settings schema
-
# @option options [Proc] :can_render Optional proc to determine if block should render
-
#
-
# @example
-
# Railspress::PluginBlocks.register(:seo_analyzer, {
-
# label: 'SEO Analyzer',
-
# description: 'AI-powered SEO analysis and suggestions',
-
# icon: '<svg>...</svg>',
-
# locations: [:post, :page],
-
# position: :sidebar,
-
# order: 10,
-
# partial: 'plugins/ai_seo/analyzer_block',
-
# can_render: ->(context) { context[:user].admin? }
-
# })
-
def register(key, options = {})
-
validate_block_options!(key, options)
-
@blocks[key] = {
-
key: key,
-
label: options[:label] || key.to_s.titleize,
-
description: options[:description] || '',
-
icon: options[:icon],
-
locations: Array(options[:locations] || [:post, :page]),
-
position: options[:position] || :sidebar,
-
order: options[:order] || 100,
-
partial: options[:partial],
-
render_proc: options[:render_proc],
-
settings: options[:settings] || {},
-
can_render: options[:can_render],
-
plugin_name: options[:plugin_name]
-
}.freeze
-
end
-
-
# Unregister a block
-
def unregister(key)
-
@blocks.delete(key)
-
end
-
-
# Get all registered blocks
-
def all
-
@blocks.values
-
end
-
-
# Get blocks for a specific location and position
-
#
-
# @param location [Symbol] The location (e.g., :post, :page)
-
# @param position [Symbol] The position (e.g., :sidebar, :main)
-
# @param context [Hash] Context for rendering (user, record, etc.)
-
# @return [Array<Hash>] Sorted array of block configurations
-
def for_location(location, position: nil, context: {})
-
blocks = @blocks.values.select do |block|
-
next false unless block[:locations].include?(location)
-
next false if position && block[:position] != position
-
next false if block[:can_render] && !block[:can_render].call(context)
-
true
-
end
-
-
blocks.sort_by { |b| b[:order] }
-
end
-
-
# Get a specific block by key
-
def get(key)
-
@blocks[key]
-
end
-
-
# Check if a block exists
-
def exists?(key)
-
@blocks.key?(key)
-
end
-
-
# Clear all blocks (useful for testing)
-
def clear!
-
@blocks = {}
-
end
-
-
# Render a block
-
#
-
# @param key [Symbol] The block key
-
# @param context [Hash] Context for rendering
-
# @param view_context [ActionView::Base] The view context
-
# @return [String] Rendered HTML
-
def render(key, context: {}, view_context:)
-
block = get(key)
-
return '' unless block
-
-
# Ensure context is a hash
-
unless context.is_a?(Hash)
-
Rails.logger.warn("Plugin block context is not a hash for #{key}: #{context.class}")
-
context = {}
-
end
-
-
return '' if block[:can_render] && !block[:can_render].call(context)
-
-
if block[:render_proc]
-
view_context.instance_exec(context, &block[:render_proc])
-
elsif block[:partial]
-
# Create a clean locals hash
-
locals_hash = context.is_a?(Hash) ? context.dup : {}
-
locals_hash[:block] = block
-
-
view_context.render(
-
partial: block[:partial],
-
locals: locals_hash
-
)
-
else
-
''
-
end
-
rescue => e
-
Rails.logger.error("Error rendering plugin block #{key}: #{e.message}")
-
Rails.logger.error("Context class: #{context.class}")
-
Rails.logger.error("Context value: #{context.inspect}")
-
Rails.logger.error(e.backtrace.join("\n"))
-
-
if Rails.env.development?
-
view_context.content_tag(:div, class: 'p-4 bg-red-500/10 border border-red-500/20 rounded text-red-400 text-sm') do
-
"Error rendering block #{key}: #{e.message}<br/>Context: #{context.class}".html_safe
-
end
-
else
-
''
-
end
-
end
-
-
# Render all blocks for a location and position
-
#
-
# @param location [Symbol] The location
-
# @param position [Symbol] The position
-
# @param context [Hash] Context for rendering
-
# @param view_context [ActionView::Base] The view context
-
# @return [String] Rendered HTML
-
def render_all(location, position: nil, context: {}, view_context:)
-
blocks = for_location(location, position: position, context: context)
-
blocks.map { |block| render(block[:key], context: context, view_context: view_context) }.join.html_safe
-
end
-
-
private
-
-
def validate_block_options!(key, options)
-
raise ArgumentError, "Block key must be a symbol" unless key.is_a?(Symbol)
-
raise ArgumentError, "Block must have either :partial or :render_proc" unless options[:partial] || options[:render_proc]
-
-
if options[:locations] && !options[:locations].is_a?(Array)
-
raise ArgumentError, "Block locations must be an array"
-
end
-
-
if options[:position] && ![:sidebar, :main, :footer, :header, :toolbar].include?(options[:position])
-
raise ArgumentError, "Invalid block position: #{options[:position]}"
-
end
-
end
-
end
-
end
-
end
-
-
module Railspress
-
module PluginJobs
-
# Base class for plugin background jobs
-
class Base < ApplicationJob
-
queue_as :default
-
-
# Override in subclass
-
def perform(*args)
-
raise NotImplementedError, "Subclass must implement #perform"
-
end
-
end
-
-
# Helper methods for plugins to create and schedule jobs
-
module Helpers
-
# Create a background job for the plugin
-
# Example:
-
# create_job('SendEmailJob') do |job|
-
# def perform(user_id, message)
-
# user = User.find(user_id)
-
# PluginMailer.send_email(user, message).deliver_now
-
# end
-
# end
-
def create_job(job_name, &block)
-
job_class_name = "#{plugin_identifier.camelize}::#{job_name}"
-
-
# Define job class dynamically
-
job_class = Class.new(Railspress::PluginJobs::Base) do
-
class_eval(&block) if block_given?
-
end
-
-
# Set constant
-
plugin_module = plugin_identifier.camelize.constantize rescue Object.const_set(plugin_identifier.camelize, Module.new)
-
plugin_module.const_set(job_name, job_class)
-
-
job_class
-
end
-
-
# Enqueue a job to run immediately
-
# Example:
-
# enqueue_job(SendEmailJob, user.id, 'Hello')
-
def enqueue_job(job_class, *args)
-
job_class.perform_later(*args)
-
log("Enqueued job: #{job_class.name}")
-
end
-
-
# Schedule a job to run at specific time
-
# Example:
-
# schedule_job(SendEmailJob, 1.hour.from_now, user.id, 'Reminder')
-
def schedule_job(job_class, run_at, *args)
-
job_class.set(wait_until: run_at).perform_later(*args)
-
log("Scheduled job: #{job_class.name} at #{run_at}")
-
end
-
-
# Schedule a job to run after delay
-
# Example:
-
# schedule_job_in(SendEmailJob, 30.minutes, user.id, 'Follow-up')
-
def schedule_job_in(job_class, delay, *args)
-
job_class.set(wait: delay).perform_later(*args)
-
log("Scheduled job: #{job_class.name} in #{delay}")
-
end
-
-
# Schedule recurring job (using Sidekiq-cron)
-
# Example:
-
# schedule_recurring_job('daily_cleanup', '0 2 * * *', CleanupJob)
-
def schedule_recurring_job(job_name, cron_expression, job_class, *args)
-
require 'sidekiq-cron'
-
-
Sidekiq::Cron::Job.create(
-
name: "#{plugin_identifier}_#{job_name}",
-
cron: cron_expression,
-
class: job_class.name,
-
args: args.to_json
-
)
-
-
log("Scheduled recurring job: #{job_name} (#{cron_expression})")
-
rescue LoadError
-
log("Sidekiq-cron not available. Install it to use recurring jobs.", :warn)
-
end
-
-
# Remove recurring job
-
# Example:
-
# remove_recurring_job('daily_cleanup')
-
def remove_recurring_job(job_name)
-
require 'sidekiq-cron'
-
-
Sidekiq::Cron::Job.destroy("#{plugin_identifier}_#{job_name}")
-
log("Removed recurring job: #{job_name}")
-
rescue LoadError
-
# Silently skip if sidekiq-cron not available
-
end
-
-
# Get all recurring jobs for this plugin
-
def recurring_jobs
-
require 'sidekiq-cron'
-
-
prefix = "#{plugin_identifier}_"
-
Sidekiq::Cron::Job.all.select { |job| job.name.start_with?(prefix) }
-
rescue LoadError
-
[]
-
end
-
-
# Check if Sidekiq is available
-
def sidekiq_available?
-
defined?(Sidekiq)
-
end
-
-
# Get job queue name
-
def job_queue
-
:"#{plugin_identifier}_jobs"
-
end
-
-
# Set custom queue for plugin jobs
-
# Example:
-
# use_queue(:critical) # or :default, :low_priority
-
def use_queue(queue_name)
-
@job_queue = queue_name
-
end
-
-
# Enqueue multiple jobs at once
-
# Example:
-
# enqueue_batch([
-
# [SendEmailJob, user1.id],
-
# [SendEmailJob, user2.id],
-
# [ProcessDataJob, data.id]
-
# ])
-
def enqueue_batch(jobs)
-
jobs.each do |job_class, *args|
-
enqueue_job(job_class, *args)
-
end
-
end
-
-
# Check job status (if using Sidekiq Pro)
-
def job_status(job_id)
-
return unless sidekiq_available?
-
-
# This requires Sidekiq Pro
-
# Sidekiq::Status.get(job_id)
-
end
-
-
# Clear all jobs for this plugin
-
def clear_plugin_jobs
-
return unless sidekiq_available?
-
-
# Remove from Redis queue
-
Sidekiq::Queue.all.each do |queue|
-
queue.each do |job|
-
job.delete if job.klass.start_with?(plugin_identifier.camelize)
-
end
-
end
-
-
log("Cleared all plugin jobs from queues")
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
1
module Railspress
-
1
module PluginSystem
-
1
class << self
-
1
attr_accessor :plugins, :hooks, :filters, :admin_pages, :plugin_routes
-
-
1
def initialize_system
-
1
@plugins = {}
-
1
@hooks = Hash.new { |hash, key| hash[key] = [] }
-
1
@filters = Hash.new { |hash, key| hash[key] = [] }
-
1
@admin_pages = Hash.new { |hash, key| hash[key] = [] }
-
1
@plugin_routes = {}
-
1
@plugin_admin_routes = {}
-
1
@plugin_frontend_routes = {}
-
-
# Enhanced plugin features
-
1
@webhooks = Hash.new { |hash, key| hash[key] = [] }
-
1
@event_listeners = Hash.new { |hash, key| hash[key] = [] }
-
1
@middleware_stack = Hash.new { |hash, key| hash[key] = [] }
-
1
@assets = Hash.new { |hash, key| hash[key] = [] }
-
1
@api_endpoints = Hash.new { |hash, key| hash[key] = [] }
-
1
@theme_templates = Hash.new { |hash, key| hash[key] = [] }
-
1
@theme_assets = Hash.new { |hash, key| hash[key] = [] }
-
1
@theme_settings = Hash.new { |hash, key| hash[key] = [] }
-
1
@validators = Hash.new { |hash, key| hash[key] = [] }
-
1
@commands = Hash.new { |hash, key| hash[key] = [] }
-
1
@scheduled_tasks = Hash.new { |hash, key| hash[key] = [] }
-
-
1
@initialized = true
-
end
-
-
# Register a plugin
-
1
def register_plugin(name, plugin_class)
-
@plugins[name] = plugin_class
-
Rails.logger.info "Plugin registered: #{name}"
-
end
-
-
# Reload plugins (for development)
-
1
def reload_plugins
-
else: 0
then: 0
return unless Rails.env.development?
-
-
# Clear existing state completely
-
@plugins = {}
-
@hooks = Hash.new { |hash, key| hash[key] = [] }
-
-
# Reload all active plugins
-
load_plugins
-
-
Rails.logger.info "Plugins reloaded: #{loaded_plugins.join(', ')}"
-
puts "✅ Plugins reloaded: #{loaded_plugins.join(', ')}"
-
end
-
-
# Load all active plugins from database
-
1
def load_plugins
-
1
else: 1
then: 0
initialize_system unless @plugins
-
-
# Skip loading plugins if database tables don't exist yet (e.g., during migrations)
-
1
else: 1
then: 0
return unless ActiveRecord::Base.connection.table_exists?('plugins')
-
-
1
Plugin.active.find_each do |plugin_record|
-
# Use dynamic plugin discovery to find the correct plugin file
-
plugin_path = find_plugin_file(plugin_record.name)
-
-
if plugin_path && File.exist?(plugin_path)
-
begin
-
then: 0
# Use load instead of require for development reloading
-
then: 0
else: 0
load_method = Rails.env.development? ? :load : :require
-
send(load_method, plugin_path)
-
-
# Find and instantiate the plugin class
-
plugin_instance = instantiate_plugin(plugin_record.name)
-
if plugin_instance
-
then: 0
# Call the activate method to register hooks
-
plugin_instance.activate
-
@plugins[plugin_record.name] = plugin_instance
-
Rails.logger.info "Loaded, instantiated, and activated plugin: #{plugin_record.name}"
-
else: 0
else
-
Rails.logger.warn "Failed to instantiate plugin: #{plugin_record.name}"
-
end
-
rescue => e
-
Rails.logger.error "Failed to load plugin #{plugin_record.name}: #{e.message}"
-
end
-
else: 0
else
-
Rails.logger.warn "Plugin file not found for: #{plugin_record.name}"
-
end
-
end
-
end
-
-
# Find plugin file using dynamic discovery
-
1
def find_plugin_file(plugin_name)
-
plugins_dir = Rails.root.join('lib', 'plugins')
-
else: 0
then: 0
return nil unless Dir.exist?(plugins_dir)
-
-
Dir.glob(File.join(plugins_dir, '*')).each do |plugin_dir|
-
else: 0
then: 0
next unless File.directory?(plugin_dir)
-
-
candidate_name = File.basename(plugin_dir)
-
plugin_file = File.join(plugin_dir, "#{candidate_name}.rb")
-
-
else: 0
then: 0
next unless File.exist?(plugin_file)
-
-
begin
-
# Load the plugin file to check if it matches our plugin
-
load plugin_file
-
plugin_class_name = candidate_name.classify
-
plugin_class = plugin_class_name.constantize rescue nil
-
-
else: 0
if plugin_class && plugin_class.ancestors.include?(Railspress::PluginBase)
-
then: 0
# Create a temporary instance to check the name
-
temp_instance = plugin_class.new
-
then: 0
else: 0
if temp_instance.name == plugin_name
-
return plugin_file
-
end
-
end
-
rescue => e
-
# Continue to next plugin if this one fails
-
next
-
end
-
end
-
-
nil
-
end
-
-
# Instantiate a plugin by name
-
1
def instantiate_plugin(plugin_name)
-
plugins_dir = Rails.root.join('lib', 'plugins')
-
else: 0
then: 0
return nil unless Dir.exist?(plugins_dir)
-
-
Dir.glob(File.join(plugins_dir, '*')).each do |plugin_dir|
-
else: 0
then: 0
next unless File.directory?(plugin_dir)
-
-
candidate_name = File.basename(plugin_dir)
-
plugin_file = File.join(plugin_dir, "#{candidate_name}.rb")
-
-
else: 0
then: 0
next unless File.exist?(plugin_file)
-
-
begin
-
plugin_class_name = candidate_name.classify
-
plugin_class = plugin_class_name.constantize rescue nil
-
-
else: 0
if plugin_class && plugin_class.ancestors.include?(Railspress::PluginBase)
-
then: 0
# Create a temporary instance to check the name
-
temp_instance = plugin_class.new
-
then: 0
else: 0
if temp_instance.name == plugin_name
-
return temp_instance
-
end
-
end
-
rescue => e
-
# Continue to next plugin if this one fails
-
next
-
end
-
end
-
-
nil
-
end
-
-
# Add an action hook
-
1
def add_action(hook_name, callback, priority = 10, plugin_name = nil)
-
@hooks ||= Hash.new { |hash, key| hash[key] = [] }
-
@hooks[hook_name] << {
-
callback: callback,
-
priority: priority,
-
plugin_name: plugin_name
-
}
-
@hooks[hook_name].sort_by! { |h| h[:priority] }
-
end
-
-
# Execute action hooks
-
1
def do_action(hook_name, *args)
-
else: 0
then: 0
return unless @hooks[hook_name]
-
-
results = []
-
@hooks[hook_name].each do |hook|
-
# Skip hooks from deactivated plugins
-
else: 0
then: 0
next unless plugin_active?(hook[:plugin_name])
-
-
begin
-
then: 0
if hook[:callback].respond_to?(:call)
-
result = hook[:callback].call(*args)
-
else: 0
then: 0
else: 0
results << result if result
-
else: 0
elsif hook[:callback].is_a?(Symbol) || hook[:callback].is_a?(String)
-
then: 0
# If it's a method name, try to call it
-
method_name = hook[:callback].to_sym
-
then: 0
else: 0
if self.respond_to?(method_name)
-
result = self.send(method_name, *args)
-
then: 0
else: 0
results << result if result
-
end
-
end
-
rescue => e
-
Rails.logger.error "Error executing hook #{hook_name} from plugin #{hook[:plugin_name]}: #{e.message}"
-
end
-
end
-
-
# Return the results joined together (for HTML output)
-
results.join.html_safe
-
end
-
-
# Add a filter hook
-
1
def add_filter(filter_name, callback, priority = 10)
-
@filters ||= Hash.new { |hash, key| hash[key] = [] }
-
@filters[filter_name] << { callback: callback, priority: priority }
-
@filters[filter_name].sort_by! { |f| f[:priority] }
-
end
-
-
# Apply filters
-
1
def apply_filters(filter_name, value, *args)
-
else: 0
then: 0
return value unless @filters[filter_name]
-
-
@filters[filter_name].reduce(value) do |filtered_value, filter|
-
begin
-
then: 0
if filter[:callback].respond_to?(:call)
-
filter[:callback].call(filtered_value, *args)
-
else: 0
else
-
filtered_value
-
end
-
rescue => e
-
Rails.logger.error "Error applying filter #{filter_name}: #{e.message}"
-
filtered_value
-
end
-
end
-
end
-
-
# Check if plugin is loaded
-
1
def plugin_loaded?(name)
-
@plugins.key?(name)
-
end
-
-
# Get plugin instance
-
1
def get_plugin(name)
-
@plugins[name]
-
end
-
-
# Get all loaded plugins
-
1
def loaded_plugins
-
1
@plugins.keys
-
end
-
-
# Check if a plugin is active
-
1
def plugin_active?(plugin_name)
-
else: 0
then: 0
return true unless plugin_name # Allow hooks without plugin names (backward compatibility)
-
-
# Check if plugin exists and is active in the database
-
else: 0
then: 0
return false unless ActiveRecord::Base.connection.table_exists?('plugins')
-
-
Plugin.exists?(name: plugin_name, active: true)
-
end
-
-
# Register admin page for a plugin
-
1
def register_admin_page(plugin_identifier, page_config)
-
@admin_pages ||= Hash.new { |hash, key| hash[key] = [] }
-
@admin_pages[plugin_identifier] << page_config
-
Rails.logger.info "Registered admin page for #{plugin_identifier}: #{page_config[:title]}"
-
end
-
-
# Get all admin pages for a plugin
-
1
def get_plugin_admin_pages(plugin_identifier)
-
then: 0
else: 0
@admin_pages&.dig(plugin_identifier) || []
-
end
-
-
# Register plugin routes
-
1
def register_plugin_routes(plugin_identifier, routes_block)
-
@plugin_routes ||= {}
-
@plugin_routes[plugin_identifier] = routes_block
-
Rails.logger.info "Registered routes for plugin: #{plugin_identifier}"
-
end
-
-
# Register admin routes for a plugin
-
1
def register_plugin_admin_routes(plugin_identifier, routes_block)
-
@plugin_admin_routes ||= {}
-
@plugin_admin_routes[plugin_identifier] = routes_block
-
Rails.logger.info "Registered admin routes for plugin: #{plugin_identifier}"
-
end
-
-
# Register frontend routes for a plugin
-
1
def register_plugin_frontend_routes(plugin_identifier, routes_block)
-
@plugin_frontend_routes ||= {}
-
@plugin_frontend_routes[plugin_identifier] = routes_block
-
Rails.logger.info "Registered frontend routes for plugin: #{plugin_identifier}"
-
end
-
-
# Get all plugin admin pages
-
1
def all_plugin_admin_pages
-
then: 0
else: 0
then: 0
else: 0
@admin_pages&.values&.flatten || []
-
end
-
-
# Get all plugin routes
-
1
def all_plugin_routes
-
@plugin_routes || {}
-
end
-
-
# Load all plugin routes into the Rails router
-
# Called from config/initializers/plugin_system.rb after plugins are loaded
-
1
def load_plugin_routes!
-
1
total_routes = 0
-
1
then: 1
else: 0
total_routes += @plugin_admin_routes&.size || 0
-
1
then: 1
else: 0
total_routes += @plugin_frontend_routes&.size || 0
-
1
then: 1
else: 0
total_routes += @plugin_routes&.size || 0
-
-
1
then: 1
else: 0
return if total_routes == 0
-
-
Rails.logger.info "Loading routes for #{total_routes} plugin route blocks..."
-
-
Rails.application.routes.append do
-
# Load admin routes (scoped under /admin for security)
-
then: 0
else: 0
then: 0
else: 0
if @plugin_admin_routes&.any?
-
Rails.logger.info "Loading admin routes..."
-
namespace :admin do
-
@plugin_admin_routes.each do |plugin_identifier, routes_block|
-
begin
-
Rails.logger.info " → Loading admin routes for: #{plugin_identifier}"
-
-
# Wrap each plugin's routes in a namespace for isolation
-
namespace plugin_identifier.underscore.to_sym do
-
then: 0
else: 0
instance_eval(&routes_block) if routes_block
-
end
-
-
rescue => e
-
Rails.logger.error " ✗ Failed to load admin routes for #{plugin_identifier}: #{e.message}"
-
Rails.logger.error e.backtrace.first(5).join("\n")
-
end
-
end
-
end
-
end
-
-
# Load frontend routes (scoped under /plugins for security)
-
then: 0
else: 0
then: 0
else: 0
if @plugin_frontend_routes&.any?
-
Rails.logger.info "Loading frontend routes..."
-
scope '/plugins' do
-
@plugin_frontend_routes.each do |plugin_identifier, routes_block|
-
begin
-
Rails.logger.info " → Loading frontend routes for: #{plugin_identifier}"
-
-
# Wrap each plugin's routes in a scope for isolation
-
scope plugin_identifier.underscore do
-
then: 0
else: 0
instance_eval(&routes_block) if routes_block
-
end
-
-
rescue => e
-
Rails.logger.error " ✗ Failed to load frontend routes for #{plugin_identifier}: #{e.message}"
-
Rails.logger.error e.backtrace.first(5).join("\n")
-
end
-
end
-
end
-
end
-
-
# Load legacy routes (backward compatibility - treated as admin routes)
-
then: 0
else: 0
then: 0
else: 0
if @plugin_routes&.any?
-
Rails.logger.info "Loading legacy routes (as admin routes)..."
-
namespace :admin do
-
@plugin_routes.each do |plugin_identifier, routes_block|
-
begin
-
Rails.logger.info " → Loading legacy routes for: #{plugin_identifier}"
-
-
namespace plugin_identifier.underscore.to_sym do
-
then: 0
else: 0
instance_eval(&routes_block) if routes_block
-
end
-
-
rescue => e
-
Rails.logger.error " ✗ Failed to load legacy routes for #{plugin_identifier}: #{e.message}"
-
Rails.logger.error e.backtrace.first(5).join("\n")
-
end
-
end
-
end
-
end
-
end
-
-
Rails.logger.info "✓ Plugin routes loaded successfully"
-
rescue => e
-
Rails.logger.error "Failed to load plugin routes: #{e.message}"
-
Rails.logger.error e.backtrace.first(10).join("\n")
-
end
-
-
# ========================================
-
# WEBHOOK SYSTEM
-
# ========================================
-
-
1
def register_webhook(plugin_identifier, webhook)
-
@webhooks[plugin_identifier] << webhook
-
Rails.logger.info "Registered webhook for #{plugin_identifier}: #{webhook[:event]}"
-
end
-
-
1
def trigger_webhook(plugin_identifier, event_name, data)
-
webhooks = @webhooks[plugin_identifier].select { |w| w[:event] == event_name && w[:active] }
-
-
webhooks.each do |webhook|
-
WebhookJob.perform_later(webhook, data)
-
end
-
-
Rails.logger.info "Triggered #{webhooks.size} webhooks for #{plugin_identifier}:#{event_name}"
-
end
-
-
# ========================================
-
# EVENT SYSTEM
-
# ========================================
-
-
1
def register_event_listener(plugin_identifier, event)
-
@event_listeners[plugin_identifier] << event
-
Rails.logger.info "Registered event listener for #{plugin_identifier}: #{event[:name]}"
-
end
-
-
1
def emit_event(event_name, data = {})
-
listeners = []
-
@event_listeners.each do |plugin_id, events|
-
events.select { |e| e[:name] == event_name }.each do |event|
-
listeners << { plugin: plugin_id, callback: event[:callback] }
-
end
-
end
-
-
listeners.sort_by { |l| l[:priority] || 10 }.each do |listener|
-
begin
-
listener[:callback].call(data)
-
rescue => e
-
Rails.logger.error "Event listener error in #{listener[:plugin]}: #{e.message}"
-
end
-
end
-
-
Rails.logger.info "Emitted event #{event_name} to #{listeners.size} listeners"
-
end
-
-
# ========================================
-
# MIDDLEWARE SYSTEM
-
# ========================================
-
-
1
def register_middleware(plugin_identifier, middleware)
-
@middleware_stack[plugin_identifier] << middleware
-
Rails.logger.info "Registered middleware for #{plugin_identifier}: #{middleware[:class]}"
-
end
-
-
1
def load_plugin_middleware
-
@middleware_stack.each do |plugin_id, middleware_list|
-
middleware_list.each do |middleware|
-
begin
-
Rails.application.middleware.use middleware[:class], *middleware[:args], &middleware[:block]
-
Rails.logger.info "Loaded middleware for #{plugin_id}: #{middleware[:class]}"
-
rescue => e
-
Rails.logger.error "Failed to load middleware for #{plugin_id}: #{e.message}"
-
end
-
end
-
end
-
end
-
-
# ========================================
-
# ASSET MANAGEMENT
-
# ========================================
-
-
1
def register_asset(plugin_identifier, asset)
-
@assets[plugin_identifier] << asset
-
Rails.logger.info "Registered asset for #{plugin_identifier}: #{asset[:path]}"
-
end
-
-
1
def get_plugin_assets(plugin_identifier, type = nil, context = :all)
-
assets = @assets[plugin_identifier]
-
-
then: 0
else: 0
assets = assets.select { |a| a[:type] == type } if type
-
then: 0
else: 0
assets = assets.select { |a| a[:admin_only] == true } if context == :admin
-
then: 0
else: 0
assets = assets.select { |a| a[:frontend_only] == true } if context == :frontend
-
-
assets.sort_by { |a| a[:priority] || 10 }
-
end
-
-
# ========================================
-
# API ENDPOINTS
-
# ========================================
-
-
1
def register_api_endpoint(plugin_identifier, endpoint)
-
@api_endpoints[plugin_identifier] << endpoint
-
Rails.logger.info "Registered API endpoint for #{plugin_identifier}: #{endpoint[:method]} #{endpoint[:path]}"
-
end
-
-
1
def load_plugin_api_routes
-
else: 0
then: 0
return unless @api_endpoints.any?
-
-
Rails.application.routes.append do
-
namespace :api do
-
@api_endpoints.each do |plugin_id, endpoints|
-
namespace plugin_id.underscore.to_sym do
-
endpoints.each do |endpoint|
-
begin
-
send(endpoint[:method].downcase, endpoint[:path],
-
to: "#{endpoint[:controller]}##{endpoint[:action]}",
-
defaults: { plugin: plugin_id })
-
rescue => e
-
Rails.logger.error "Failed to register API route for #{plugin_id}: #{e.message}"
-
end
-
end
-
end
-
end
-
end
-
end
-
end
-
-
# ========================================
-
# THEME SYSTEM
-
# ========================================
-
-
1
def register_theme_template(plugin_identifier, template)
-
@theme_templates[plugin_identifier] << template
-
Rails.logger.info "Registered theme template for #{plugin_identifier}: #{template[:name]}"
-
end
-
-
1
def register_theme_asset(plugin_identifier, asset)
-
@theme_assets[plugin_identifier] << asset
-
Rails.logger.info "Registered theme asset for #{plugin_identifier}: #{asset[:path]}"
-
end
-
-
1
def register_theme_setting(plugin_identifier, setting)
-
@theme_settings[plugin_identifier] << setting
-
Rails.logger.info "Registered theme setting for #{plugin_identifier}: #{setting[:key]}"
-
end
-
-
# ========================================
-
# VALIDATORS
-
# ========================================
-
-
1
def register_validator(plugin_identifier, validator)
-
@validators[plugin_identifier] << validator
-
Rails.logger.info "Registered validator for #{plugin_identifier}: #{validator[:name]}"
-
end
-
-
# ========================================
-
# COMMANDS
-
# ========================================
-
-
1
def register_command(plugin_identifier, command)
-
@commands[plugin_identifier] << command
-
Rails.logger.info "Registered command for #{plugin_identifier}: #{command[:name]}"
-
end
-
-
1
def load_plugin_commands
-
@commands.each do |plugin_id, commands|
-
commands.each do |command|
-
begin
-
Rake::Task.define_task("#{plugin_id}:#{command[:name]}") do |t|
-
puts "Running #{plugin_id}:#{command[:name]} - #{command[:description]}"
-
command[:block].call
-
end
-
rescue => e
-
Rails.logger.error "Failed to register command for #{plugin_id}: #{e.message}"
-
end
-
end
-
end
-
end
-
-
# ========================================
-
# NOTIFICATIONS
-
# ========================================
-
-
1
def notify_admin(plugin_identifier, message, type, options = {})
-
# Create admin notification
-
AdminNotification.create!(
-
plugin: plugin_identifier,
-
message: message,
-
notification_type: type,
-
metadata: options
-
)
-
Rails.logger.info "Admin notification sent from #{plugin_identifier}: #{message}"
-
end
-
-
1
def notify_user(plugin_identifier, user_id, message, type, options = {})
-
# Create user notification
-
UserNotification.create!(
-
plugin: plugin_identifier,
-
user_id: user_id,
-
message: message,
-
notification_type: type,
-
metadata: options
-
)
-
Rails.logger.info "User notification sent from #{plugin_identifier} to user #{user_id}: #{message}"
-
end
-
-
# ========================================
-
# SCHEDULER
-
# ========================================
-
-
1
def schedule_task(plugin_identifier, task)
-
@scheduled_tasks[plugin_identifier] << task
-
Rails.logger.info "Scheduled task for #{plugin_identifier}: #{task[:name]}"
-
-
# Schedule with cron job system
-
then: 0
else: 0
if defined?(Sidekiq)
-
Sidekiq::Cron::Job.create(
-
name: "#{plugin_identifier}:#{task[:name]}",
-
cron: task[:cron],
-
class: 'PluginTaskWorker',
-
args: [plugin_identifier, task[:name]]
-
)
-
end
-
end
-
-
# ========================================
-
# DATABASE HELPERS
-
# ========================================
-
-
1
def create_plugin_migration(plugin_identifier, table_name, &block)
-
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
-
filename = "#{timestamp}_create_#{plugin_identifier}_#{table_name}.rb"
-
-
migration_path = Rails.root.join('db', 'migrate', filename)
-
-
migration_content = <<~RUBY
-
class Create#{plugin_identifier.classify}#{table_name.classify} < ActiveRecord::Migration[7.1]
-
def change
-
create_table :#{plugin_identifier}_#{table_name} do |t|
-
then: 0
else: 0
#{block ? block.call : '# Add columns here'}
-
t.timestamps
-
end
-
end
-
end
-
RUBY
-
-
File.write(migration_path, migration_content)
-
Rails.logger.info "Created migration: #{filename}"
-
-
migration_path
-
end
-
-
1
def add_plugin_column(plugin_identifier, table_name, column_name, type, options = {})
-
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
-
filename = "#{timestamp}_add_#{column_name}_to_#{plugin_identifier}_#{table_name}.rb"
-
-
migration_path = Rails.root.join('db', 'migrate', filename)
-
-
migration_content = <<~RUBY
-
class Add#{column_name.classify}To#{plugin_identifier.classify}#{table_name.classify} < ActiveRecord::Migration[7.1]
-
def change
-
then: 0
else: 0
add_column :#{plugin_identifier}_#{table_name}, :#{column_name}, :#{type}#{options.empty? ? '' : ', ' + options.inspect}
-
end
-
end
-
RUBY
-
-
File.write(migration_path, migration_content)
-
Rails.logger.info "Created column migration: #{filename}"
-
-
migration_path
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
module Railspress
-
class SettingsSchema
-
attr_reader :sections, :plugin_name
-
-
def initialize(plugin_name)
-
@plugin_name = plugin_name
-
@sections = []
-
end
-
-
# Define a settings section
-
def section(title, **options, &block)
-
section = Section.new(title, options)
-
section.instance_eval(&block) if block_given?
-
@sections << section
-
section
-
end
-
-
# Get all fields from all sections
-
def all_fields
-
@sections.flat_map(&:fields)
-
end
-
-
# Get field by key
-
def find_field(key)
-
all_fields.find { |f| f.key == key.to_s }
-
end
-
-
# Validate settings hash
-
def validate(settings)
-
errors = {}
-
-
all_fields.each do |field|
-
value = settings[field.key]
-
field_errors = field.validate(value)
-
errors[field.key] = field_errors if field_errors.any?
-
end
-
-
errors
-
end
-
-
# Section class
-
class Section
-
attr_reader :title, :description, :fields
-
-
def initialize(title, options = {})
-
@title = title
-
@description = options[:description]
-
@fields = []
-
end
-
-
# Field types
-
def text(key, label, **options)
-
add_field(TextField.new(key, label, options))
-
end
-
-
def textarea(key, label, **options)
-
add_field(TextareaField.new(key, label, options))
-
end
-
-
def number(key, label, **options)
-
add_field(NumberField.new(key, label, options))
-
end
-
-
def checkbox(key, label, **options)
-
add_field(CheckboxField.new(key, label, options))
-
end
-
-
def select(key, label, choices, **options)
-
add_field(SelectField.new(key, label, choices, options))
-
end
-
-
def radio(key, label, choices, **options)
-
add_field(RadioField.new(key, label, choices, options))
-
end
-
-
def email(key, label, **options)
-
add_field(EmailField.new(key, label, options))
-
end
-
-
def url(key, label, **options)
-
add_field(UrlField.new(key, label, options))
-
end
-
-
def color(key, label, **options)
-
add_field(ColorField.new(key, label, options))
-
end
-
-
def file(key, label, **options)
-
add_field(FileField.new(key, label, options))
-
end
-
-
def wysiwyg(key, label, **options)
-
add_field(WysiwygField.new(key, label, options))
-
end
-
-
def code(key, label, **options)
-
add_field(CodeField.new(key, label, options))
-
end
-
-
def custom(key, label, **options, &block)
-
add_field(CustomField.new(key, label, options, &block))
-
end
-
-
private
-
-
def add_field(field)
-
@fields << field
-
field
-
end
-
end
-
-
# Base field class
-
class BaseField
-
attr_reader :key, :label, :options
-
-
def initialize(key, label, options = {})
-
@key = key.to_s
-
@label = label
-
@options = options
-
end
-
-
def required?
-
@options[:required] == true
-
end
-
-
def default
-
@options[:default]
-
end
-
-
def description
-
@options[:description]
-
end
-
-
def placeholder
-
@options[:placeholder]
-
end
-
-
def validate(value)
-
errors = []
-
-
if required? && value.blank?
-
errors << "#{label} is required"
-
end
-
-
if @options[:min] && value.to_i < @options[:min]
-
errors << "#{label} must be at least #{@options[:min]}"
-
end
-
-
if @options[:max] && value.to_i > @options[:max]
-
errors << "#{label} must be at most #{@options[:max]}"
-
end
-
-
if @options[:pattern] && value.present? && !value.match?(@options[:pattern])
-
errors << "#{label} format is invalid"
-
end
-
-
errors
-
end
-
-
def input_type
-
'text'
-
end
-
-
def render_options
-
{
-
type: input_type,
-
required: required?,
-
placeholder: placeholder,
-
description: description
-
}.compact
-
end
-
end
-
-
# Specific field types
-
class TextField < BaseField
-
def input_type; 'text'; end
-
end
-
-
class TextareaField < BaseField
-
def input_type; 'textarea'; end
-
def rows; @options[:rows] || 4; end
-
end
-
-
class NumberField < BaseField
-
def input_type; 'number'; end
-
def min; @options[:min]; end
-
def max; @options[:max]; end
-
def step; @options[:step] || 1; end
-
end
-
-
class CheckboxField < BaseField
-
def input_type; 'checkbox'; end
-
end
-
-
class SelectField < BaseField
-
attr_reader :choices
-
-
def initialize(key, label, choices, options = {})
-
super(key, label, options)
-
@choices = choices
-
end
-
-
def input_type; 'select'; end
-
end
-
-
class RadioField < BaseField
-
attr_reader :choices
-
-
def initialize(key, label, choices, options = {})
-
super(key, label, options)
-
@choices = choices
-
end
-
-
def input_type; 'radio'; end
-
end
-
-
class EmailField < BaseField
-
def input_type; 'email'; end
-
end
-
-
class UrlField < BaseField
-
def input_type; 'url'; end
-
end
-
-
class ColorField < BaseField
-
def input_type; 'color'; end
-
end
-
-
class FileField < BaseField
-
def input_type; 'file'; end
-
def accept; @options[:accept]; end
-
end
-
-
class WysiwygField < BaseField
-
def input_type; 'wysiwyg'; end
-
def editor; @options[:editor] || 'trix'; end
-
end
-
-
class CodeField < BaseField
-
def input_type; 'code'; end
-
def language; @options[:language] || 'plaintext'; end
-
end
-
-
class CustomField < BaseField
-
def initialize(key, label, options = {}, &block)
-
super(key, label, options)
-
@render_block = block
-
end
-
-
def input_type; 'custom'; end
-
-
def render(form_builder, value)
-
@render_block.call(form_builder, value) if @render_block
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
1
module Railspress
-
1
class ShortcodeProcessor
-
1
class << self
-
1
attr_accessor :shortcodes
-
-
1
def initialize_processor
-
1
@shortcodes = {}
-
1
register_default_shortcodes
-
end
-
-
# Register a shortcode
-
1
def register(name, &block)
-
8
@shortcodes ||= {}
-
8
@shortcodes[name.to_s] = block
-
8
Rails.logger.info "Shortcode registered: #{name}"
-
end
-
-
# Process content containing shortcodes
-
1
def process(content, context = {})
-
then: 0
else: 0
return content if content.blank?
-
-
# Pattern matches [shortcode attr="value"] or [shortcode]content[/shortcode]
-
content.gsub(/\[(\w+)([^\]]*)\](?:([^\[]*)\[\/\1\])?/) do
-
shortcode_name = $1
-
attributes_str = $2
-
inner_content = $3
-
-
then: 0
if @shortcodes.key?(shortcode_name)
-
attrs = parse_attributes(attributes_str)
-
execute_shortcode(shortcode_name, attrs, inner_content, context)
-
else
-
else: 0
# Return original if shortcode not found
-
$&
-
end
-
end
-
end
-
-
# Execute a specific shortcode
-
1
def execute_shortcode(name, attributes, content, context)
-
shortcode = @shortcodes[name]
-
else: 0
then: 0
return '' unless shortcode
-
-
begin
-
then: 0
if shortcode.arity == 3
-
else: 0
shortcode.call(attributes, content, context)
-
then: 0
elsif shortcode.arity == 2
-
shortcode.call(attributes, content)
-
else: 0
else
-
shortcode.call(attributes)
-
end
-
rescue => e
-
Rails.logger.error "Error executing shortcode #{name}: #{e.message}"
-
"[Error in #{name} shortcode]"
-
end
-
end
-
-
# Parse shortcode attributes
-
1
def parse_attributes(attr_string)
-
then: 0
else: 0
return {} if attr_string.blank?
-
-
attrs = {}
-
attr_string.scan(/(\w+)=["']([^"']+)["']|(\w+)=(\S+)/) do |match|
-
key = match[0] || match[2]
-
value = match[1] || match[3]
-
attrs[key.to_sym] = value
-
end
-
attrs
-
end
-
-
# Check if shortcode exists
-
1
def exists?(name)
-
@shortcodes.key?(name.to_s)
-
end
-
-
# Get all registered shortcodes
-
1
def all
-
1
@shortcodes.keys
-
end
-
-
# Remove a shortcode
-
1
def unregister(name)
-
@shortcodes.delete(name.to_s)
-
end
-
-
1
private
-
-
# Register default shortcodes
-
1
def register_default_shortcodes
-
# Gallery shortcode
-
1
register('gallery') do |attrs, content|
-
then: 0
else: 0
then: 0
else: 0
ids = attrs[:ids]&.split(',')&.map(&:to_i) || []
-
then: 0
else: 0
columns = attrs[:columns]&.to_i || 3
-
size = attrs[:size] || 'medium'
-
-
then: 0
if ids.any?
-
media = Medium.where(id: ids)
-
render_gallery(media, columns, size)
-
else: 0
else
-
''
-
end
-
end
-
-
# Button shortcode
-
1
register('button') do |attrs, content|
-
url = attrs[:url] || '#'
-
style = attrs[:style] || 'primary'
-
size = attrs[:size] || 'medium'
-
target = attrs[:target] || '_self'
-
-
render_button(content || 'Click Here', url, style, size, target)
-
end
-
-
# YouTube shortcode
-
1
register('youtube') do |attrs|
-
video_id = attrs[:id]
-
width = attrs[:width] || '560'
-
height = attrs[:height] || '315'
-
-
render_youtube(video_id, width, height)
-
end
-
-
# Recent Posts shortcode
-
1
register('recent_posts') do |attrs|
-
then: 0
else: 0
count = attrs[:count]&.to_i || 5
-
category = attrs[:category]
-
-
posts = Post.published.recent
-
then: 0
else: 0
posts = posts.by_category(category) if category
-
posts = posts.limit(count)
-
-
render_recent_posts(posts)
-
end
-
-
# Contact Form shortcode
-
1
register('contact_form') do |attrs|
-
form_id = attrs[:id] || 'contact'
-
email = attrs[:email] || SiteSetting.get('admin_email', 'admin@example.com')
-
-
render_contact_form(form_id, email)
-
end
-
-
# Columns shortcode
-
1
register('columns') do |attrs, content|
-
then: 0
else: 0
count = attrs[:count]&.to_i || 2
-
render_columns(content, count)
-
end
-
-
# Alert/Notice shortcode
-
1
register('alert') do |attrs, content|
-
type = attrs[:type] || 'info'
-
render_alert(content, type)
-
end
-
-
# Code shortcode
-
1
register('code') do |attrs, content|
-
language = attrs[:lang] || 'plaintext'
-
render_code(content, language)
-
end
-
end
-
-
# Rendering helpers
-
1
def render_gallery(media, columns, size)
-
then: 0
else: 0
return '' if media.empty?
-
-
html = '<div class="shortcode-gallery grid grid-cols-' + columns.to_s + ' gap-4 my-6">'
-
media.each do |item|
-
then: 0
else: 0
if item.file.attached?
-
html += '<div class="gallery-item">'
-
html += '<img src="' + Rails.application.routes.url_helpers.url_for(item.file) + '" alt="' + (item.alt_text || item.title) + '" class="w-full h-auto rounded-lg">'
-
html += '</div>'
-
end
-
end
-
html += '</div>'
-
html
-
end
-
-
1
def render_button(text, url, style, size, target)
-
color_classes = {
-
'primary' => 'bg-blue-600 hover:bg-blue-700 text-white',
-
'secondary' => 'bg-gray-600 hover:bg-gray-700 text-white',
-
'success' => 'bg-green-600 hover:bg-green-700 text-white',
-
'danger' => 'bg-red-600 hover:bg-red-700 text-white'
-
}
-
-
size_classes = {
-
'small' => 'px-3 py-1 text-sm',
-
'medium' => 'px-6 py-2',
-
'large' => 'px-8 py-3 text-lg'
-
}
-
-
classes = "inline-block #{color_classes[style]} #{size_classes[size]} rounded-lg transition font-medium"
-
-
"<a href=\"#{url}\" target=\"#{target}\" class=\"#{classes}\">#{text}</a>"
-
end
-
-
1
def render_youtube(video_id, width, height)
-
else: 0
then: 0
return '' unless video_id
-
-
"<div class=\"video-container my-6\" style=\"position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;\">
-
<iframe src=\"https://www.youtube.com/embed/#{video_id}\"
-
width=\"#{width}\"
-
height=\"#{height}\"
-
style=\"position: absolute; top: 0; left: 0; width: 100%; height: 100%;\"
-
frameborder=\"0\"
-
allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\"
-
allowfullscreen>
-
</iframe>
-
</div>"
-
end
-
-
1
def render_recent_posts(posts)
-
then: 0
else: 0
return '' if posts.empty?
-
-
html = '<div class="shortcode-recent-posts my-6 space-y-3">'
-
posts.each do |post|
-
html += '<div class="recent-post">'
-
html += '<h4 class="font-semibold"><a href="/blog/' + post.slug + '" class="text-blue-600 hover:text-blue-800">' + post.title + '</a></h4>'
-
html += '<p class="text-sm text-gray-500">' + post.published_at.strftime('%B %d, %Y') + '</p>'
-
html += '</div>'
-
end
-
html += '</div>'
-
html
-
end
-
-
1
def render_contact_form(form_id, email)
-
"<div class=\"shortcode-contact-form my-6 p-6 bg-gray-50 rounded-lg\">
-
<form action=\"/contact\" method=\"post\" class=\"space-y-4\">
-
<div>
-
<label class=\"block text-sm font-medium mb-1\">Name</label>
-
<input type=\"text\" name=\"name\" required class=\"w-full px-4 py-2 border rounded-lg\">
-
</div>
-
<div>
-
<label class=\"block text-sm font-medium mb-1\">Email</label>
-
<input type=\"email\" name=\"email\" required class=\"w-full px-4 py-2 border rounded-lg\">
-
</div>
-
<div>
-
<label class=\"block text-sm font-medium mb-1\">Message</label>
-
<textarea name=\"message\" rows=\"4\" required class=\"w-full px-4 py-2 border rounded-lg\"></textarea>
-
</div>
-
<button type=\"submit\" class=\"px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700\">Send Message</button>
-
</form>
-
</div>"
-
end
-
-
1
def render_columns(content, count)
-
"<div class=\"shortcode-columns grid grid-cols-#{count} gap-6 my-6\">
-
#{content}
-
</div>"
-
end
-
-
1
def render_alert(content, type)
-
colors = {
-
'info' => 'bg-blue-50 border-blue-500 text-blue-800',
-
'success' => 'bg-green-50 border-green-500 text-green-800',
-
'warning' => 'bg-yellow-50 border-yellow-500 text-yellow-800',
-
'danger' => 'bg-red-50 border-red-500 text-red-800'
-
}
-
-
"<div class=\"shortcode-alert #{colors[type]} border-l-4 p-4 my-6 rounded\">
-
#{content}
-
</div>"
-
end
-
-
1
def render_code(content, language)
-
"<pre class=\"shortcode-code my-6 bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto\"><code class=\"language-#{language}\">#{content}</code></pre>"
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
1
module Railspress
-
1
class ThemeLoader
-
1
class << self
-
1
attr_accessor :current_theme, :themes_path
-
-
1
def initialize_loader
-
1
@themes_path = Rails.root.join('app', 'themes')
-
1
@current_theme = nil
-
1
load_active_theme
-
end
-
-
# Load the active theme from database
-
1
def load_active_theme
-
# Skip loading theme if database tables don't exist yet (e.g., during migrations)
-
1
else: 1
then: 0
return unless ActiveRecord::Base.connection.table_exists?('themes')
-
-
1
active_theme = Theme.active.first
-
1
then: 0
else: 1
if active_theme
-
@current_theme = active_theme.name.underscore
-
setup_theme_paths
-
load_theme_initializer
-
end
-
end
-
-
# Set up view paths for theme templates
-
1
def setup_theme_paths
-
else: 0
then: 0
return unless @current_theme
-
-
theme_views_path = Rails.root.join('app', 'themes', @current_theme, 'views')
-
-
else: 0
if Dir.exist?(theme_views_path)
-
then: 0
# Get current paths and add theme path at the beginning
-
controller_paths = ActionController::Base.view_paths.paths.dup
-
mailer_paths = ActionMailer::Base.view_paths.paths.dup
-
-
# Remove any existing theme paths first
-
controller_paths.reject! { |path| path.to_s.include?('app/themes') }
-
mailer_paths.reject! { |path| path.to_s.include?('app/themes') }
-
-
# Add new theme path at the beginning
-
theme_resolver = ActionView::FileSystemResolver.new(theme_views_path.to_s)
-
controller_paths.unshift(theme_resolver)
-
mailer_paths.unshift(theme_resolver)
-
-
# Set the new paths
-
ActionController::Base.view_paths = ActionView::PathSet.new(controller_paths)
-
ActionMailer::Base.view_paths = ActionView::PathSet.new(mailer_paths)
-
end
-
end
-
-
# Load theme's initializer if exists
-
1
def load_theme_initializer
-
else: 0
then: 0
return unless @current_theme
-
-
initializer_path = Rails.root.join('app', 'themes', @current_theme, 'theme.rb')
-
-
then: 0
else: 0
if File.exist?(initializer_path)
-
load initializer_path
-
Rails.logger.info "Loaded theme initializer: #{@current_theme}"
-
end
-
end
-
-
# Get theme configuration from PublishedThemeVersion
-
1
def theme_config
-
else: 0
then: 0
return {} unless @current_theme
-
-
# First try to get from PublishedThemeVersion
-
active_theme = Theme.active.first
-
then: 0
else: 0
if active_theme
-
published_version = PublishedThemeVersion.for_theme(active_theme.name).latest.first
-
then: 0
else: 0
if published_version
-
config_file = published_version.published_theme_files.find_by(file_path: 'config/theme.json')
-
then: 0
else: 0
if config_file
-
return JSON.parse(config_file.content)
-
end
-
end
-
end
-
-
# Fallback to filesystem
-
config_path = Rails.root.join('app', 'themes', @current_theme, 'config', 'theme.json')
-
-
then: 0
if File.exist?(config_path)
-
JSON.parse(File.read(config_path))
-
else: 0
else
-
{}
-
end
-
end
-
-
# Get all available themes
-
1
def available_themes
-
else: 0
then: 0
return [] unless Dir.exist?(@themes_path)
-
-
Dir.glob(@themes_path.join('*')).select { |f| File.directory?(f) }.map do |theme_dir|
-
theme_name = File.basename(theme_dir)
-
config_path = File.join(theme_dir, 'config', 'theme.json')
-
-
then: 0
if File.exist?(config_path)
-
config = JSON.parse(File.read(config_path))
-
{
-
name: theme_name,
-
display_name: config['name'] || theme_name.titleize,
-
version: config['version'] || '1.0.0',
-
author: config['author'] || 'Unknown',
-
description: config['description'] || 'No description',
-
screenshot: config['screenshot'] || nil,
-
path: theme_dir
-
}
-
else: 0
else
-
{
-
name: theme_name,
-
display_name: theme_name.titleize,
-
version: '1.0.0',
-
author: 'Unknown',
-
description: 'No description',
-
screenshot: nil,
-
path: theme_dir
-
}
-
end
-
end
-
end
-
-
# Activate a theme
-
1
def activate_theme(theme_name)
-
theme_path = @themes_path.join(theme_name)
-
-
else: 0
then: 0
unless Dir.exist?(theme_path)
-
Rails.logger.error "Theme not found: #{theme_name}"
-
return false
-
end
-
-
# Update database
-
Theme.where.not(name: theme_name.camelize).update_all(active: false)
-
theme_record = Theme.find_or_create_by(name: theme_name.camelize) do |t|
-
config_path = theme_path.join('config', 'theme.json')
-
then: 0
else: 0
config = File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
-
t.description = config['description'] || 'No description'
-
t.author = config['author'] || 'Unknown'
-
t.version = config['version'] || '1.0.0'
-
end
-
theme_record.update(active: true)
-
-
# Clear old theme paths
-
clear_theme_paths
-
-
# Reload theme
-
@current_theme = theme_name
-
setup_theme_paths
-
load_theme_initializer
-
-
# Clear view cache
-
ActionView::LookupContext::DetailsKey.clear
-
-
# Clear Rails cache
-
then: 0
else: 0
Rails.cache.clear if Rails.cache.respond_to?(:clear)
-
-
Rails.logger.info "Activated theme: #{theme_name}"
-
true
-
end
-
-
# Clear theme-specific view paths
-
1
def clear_theme_paths
-
# Remove old theme view paths
-
then: 0
else: 0
if @current_theme
-
old_theme_views = Rails.root.join('app', 'themes', @current_theme, 'views')
-
-
# Create new view paths without theme paths (since the array might be frozen)
-
controller_paths = ActionController::Base.view_paths.paths.reject { |path| path.to_s.include?('app/themes') }
-
mailer_paths = ActionMailer::Base.view_paths.paths.reject { |path| path.to_s.include?('app/themes') }
-
-
# Set the new paths
-
ActionController::Base.view_paths = ActionView::PathSet.new(controller_paths)
-
ActionMailer::Base.view_paths = ActionView::PathSet.new(mailer_paths)
-
end
-
end
-
-
# Get theme asset path
-
1
def theme_asset_path(asset_type)
-
else: 0
then: 0
return nil unless @current_theme
-
-
Rails.root.join('app', 'themes', @current_theme, 'assets', asset_type)
-
end
-
-
# Get theme stylesheet
-
1
def theme_stylesheet
-
else: 0
then: 0
return nil unless @current_theme
-
-
stylesheet_path = theme_asset_path('stylesheets')
-
else: 0
then: 0
return nil unless stylesheet_path && Dir.exist?(stylesheet_path)
-
-
# Look for main stylesheet
-
main_css = Dir.glob(stylesheet_path.join('*.css')).first
-
then: 0
else: 0
main_css ? File.basename(main_css, '.css') : nil
-
end
-
-
# Get theme javascript
-
1
def theme_javascript
-
else: 0
then: 0
return nil unless @current_theme
-
-
js_path = theme_asset_path('javascripts')
-
else: 0
then: 0
return nil unless js_path && Dir.exist?(js_path)
-
-
# Look for main javascript
-
main_js = Dir.glob(js_path.join('*.js')).first
-
then: 0
else: 0
main_js ? File.basename(main_js, '.js') : nil
-
end
-
-
# Check if template exists in theme
-
1
def template_exists?(template_path)
-
else: 0
then: 0
return false unless @current_theme
-
-
full_path = Rails.root.join('app', 'themes', @current_theme, 'views', "#{template_path}.html.erb")
-
File.exist?(full_path)
-
end
-
-
# Get theme helper modules
-
1
def theme_helpers
-
1
else: 0
then: 1
return [] unless @current_theme
-
-
helpers_path = Rails.root.join('app', 'themes', @current_theme, 'helpers')
-
else: 0
then: 0
return [] unless Dir.exist?(helpers_path)
-
-
Dir.glob(helpers_path.join('*.rb')).map do |helper_file|
-
require helper_file
-
File.basename(helper_file, '.rb').camelize.constantize
-
end
-
end
-
-
1
private
-
-
1
def load_theme_config(theme_name)
-
config_path = @themes_path.join(theme_name, 'config.yml')
-
then: 0
else: 0
File.exist?(config_path) ? YAML.load_file(config_path) : {}
-
end
-
end
-
end
-
end
-
-
# frozen_string_literal: true
-
-
require 'net/http'
-
require 'json'
-
-
module Railspress
-
class UpdateChecker
-
GITHUB_REPO = ENV['RAILSPRESS_GITHUB_REPO'] || 'username/railspress'
-
GITHUB_API_URL = "https://api.github.com/repos/#{GITHUB_REPO}/releases/latest"
-
CURRENT_VERSION = '1.0.0'
-
-
class << self
-
def check_for_updates
-
return cached_result if cached_result && cache_valid?
-
-
begin
-
latest_version = fetch_latest_version
-
update_available = version_greater?(latest_version, CURRENT_VERSION)
-
-
result = {
-
current_version: CURRENT_VERSION,
-
latest_version: latest_version,
-
update_available: update_available,
-
checked_at: Time.current,
-
release_url: "https://github.com/#{GITHUB_REPO}/releases/latest"
-
}
-
-
cache_result(result)
-
result
-
rescue => e
-
Rails.logger.error("Update check failed: #{e.message}")
-
{
-
current_version: CURRENT_VERSION,
-
latest_version: nil,
-
update_available: false,
-
error: e.message,
-
checked_at: Time.current
-
}
-
end
-
end
-
-
def fetch_latest_version
-
uri = URI(GITHUB_API_URL)
-
-
request = Net::HTTP::Get.new(uri)
-
request['Accept'] = 'application/vnd.github.v3+json'
-
request['User-Agent'] = 'RailsPress'
-
-
# Add GitHub token if available
-
if ENV['GITHUB_TOKEN']
-
request['Authorization'] = "token #{ENV['GITHUB_TOKEN']}"
-
end
-
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
-
http.request(request)
-
end
-
-
if response.code == '200'
-
data = JSON.parse(response.body)
-
data['tag_name'].gsub(/^v/, '') # Remove 'v' prefix if present
-
else
-
raise "GitHub API returned #{response.code}: #{response.body}"
-
end
-
end
-
-
def version_greater?(version1, version2)
-
v1_parts = version1.split('.').map(&:to_i)
-
v2_parts = version2.split('.').map(&:to_i)
-
-
[v1_parts.length, v2_parts.length].max.times do |i|
-
v1 = v1_parts[i] || 0
-
v2 = v2_parts[i] || 0
-
-
return true if v1 > v2
-
return false if v1 < v2
-
end
-
-
false
-
end
-
-
def fetch_release_notes
-
uri = URI(GITHUB_API_URL)
-
-
request = Net::HTTP::Get.new(uri)
-
request['Accept'] = 'application/vnd.github.v3+json'
-
request['User-Agent'] = 'RailsPress'
-
-
if ENV['GITHUB_TOKEN']
-
request['Authorization'] = "token #{ENV['GITHUB_TOKEN']}"
-
end
-
-
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
-
http.request(request)
-
end
-
-
if response.code == '200'
-
data = JSON.parse(response.body)
-
{
-
version: data['tag_name'],
-
name: data['name'],
-
body: data['body'],
-
html_url: data['html_url'],
-
published_at: data['published_at']
-
}
-
else
-
nil
-
end
-
end
-
-
private
-
-
def cache_key
-
'railspress:update_check'
-
end
-
-
def cached_result
-
Rails.cache.read(cache_key)
-
end
-
-
def cache_result(result)
-
# Cache for 6 hours
-
Rails.cache.write(cache_key, result, expires_in: 6.hours)
-
end
-
-
def cache_valid?
-
cached = cached_result
-
return false unless cached
-
-
cached[:checked_at] && cached[:checked_at] > 6.hours.ago
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-
-
# frozen_string_literal: true
-
-
module Railspress
-
class WebhookDispatcher
-
class << self
-
# Dispatch a webhook event
-
def dispatch(event_type, resource)
-
# Find all active webhooks subscribed to this event
-
webhooks = Webhook.active.for_event(event_type)
-
-
return if webhooks.empty?
-
-
# Build payload
-
payload = build_payload(event_type, resource)
-
-
# Deliver to each webhook
-
webhooks.each do |webhook|
-
webhook.deliver(event_type, payload)
-
end
-
-
Rails.logger.info "Dispatched webhook event: #{event_type} to #{webhooks.count} webhook(s)"
-
end
-
-
private
-
-
def build_payload(event_type, resource)
-
base_payload = {
-
event: event_type,
-
timestamp: Time.current.iso8601,
-
data: serialize_resource(resource)
-
}
-
-
# Add site context
-
base_payload[:site] = {
-
name: SiteSetting.get('site_title', 'RailsPress'),
-
url: site_url
-
}
-
-
base_payload
-
end
-
-
def serialize_resource(resource)
-
case resource
-
when Post
-
{
-
id: resource.id,
-
type: 'post',
-
title: resource.title,
-
slug: resource.slug,
-
excerpt: resource.excerpt,
-
status: resource.status,
-
published_at: resource.published_at&.iso8601,
-
url: post_url(resource),
-
author: {
-
id: resource.user&.id,
-
email: resource.user&.email
-
},
-
categories: resource.category.map { |c| { id: c.id, name: c.name, slug: c.slug } },
-
tags: resource.post_tag.map { |t| { id: t.id, name: t.name, slug: t.slug } },
-
created_at: resource.created_at.iso8601,
-
updated_at: resource.updated_at.iso8601
-
}
-
when Page
-
{
-
id: resource.id,
-
type: 'page',
-
title: resource.title,
-
slug: resource.slug,
-
status: resource.status,
-
published_at: resource.published_at&.iso8601,
-
url: page_url(resource),
-
author: {
-
id: resource.user&.id,
-
email: resource.user&.email
-
},
-
created_at: resource.created_at.iso8601,
-
updated_at: resource.updated_at.iso8601
-
}
-
when Comment
-
{
-
id: resource.id,
-
type: 'comment',
-
content: resource.content,
-
author_name: resource.author_name,
-
author_email: resource.author_email,
-
status: resource.status,
-
commentable_type: resource.commentable_type,
-
commentable_id: resource.commentable_id,
-
created_at: resource.created_at.iso8601,
-
updated_at: resource.updated_at.iso8601
-
}
-
when User
-
{
-
id: resource.id,
-
type: 'user',
-
email: resource.email,
-
role: resource.role,
-
created_at: resource.created_at.iso8601,
-
updated_at: resource.updated_at.iso8601
-
}
-
when Medium
-
{
-
id: resource.id,
-
type: 'media',
-
title: resource.title,
-
created_at: resource.created_at.iso8601
-
}
-
else
-
{
-
id: resource.try(:id),
-
type: resource.class.name.underscore
-
}
-
end
-
end
-
-
def site_url
-
Rails.application.routes.url_helpers.root_url
-
rescue
-
'http://localhost:3000'
-
end
-
-
def post_url(post)
-
Rails.application.routes.url_helpers.blog_post_url(post.slug)
-
rescue
-
"#{site_url}/blog/#{post.slug}"
-
end
-
-
def page_url(page)
-
Rails.application.routes.url_helpers.page_url(page.slug)
-
rescue
-
"#{site_url}/#{page.slug}"
-
end
-
end
-
end
-
end
-
-
-
-
-
-
-
-